feat: декомпозиция JS на модули (auth, tasks, chat, app) и добавление гостевого режима с локальной нейросетью

This commit is contained in:
2026-08-09 10:40:18 +03:00
parent cc6b80d714
commit a47514648c
6 changed files with 398 additions and 349 deletions
+52 -145
View File
@@ -1,166 +1,73 @@
# === ANCHOR: IMPORTS_START ===
import os
import json import json
import sqlite3 import urllib.request
from typing import List, Optional, Dict, Any from typing import List, Dict, Any, Optional
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi import FastAPI, HTTPException, Depends, Security
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, HTMLResponse from fastapi.responses import FileResponse
from pydantic import BaseModel from pydantic import BaseModel
from llm.agent import process_chat_message from llm.agent import process_chat_message
# === ANCHOR: IMPORTS_END === from llm.db_tools import db_get_tasks
API_TOKEN = "scud_secret_token_2026"
OLLAMA_URL = "http://192.168.11.3:11434/api/chat"
MODEL_NAME = "qwen2.5:14b"
# === ANCHOR: APP_INIT_START ===
app = FastAPI(
title="SCUD Orion AI Context & Task Tracker API",
version="2.0.0",
description="REST API локального контекста, памяти и трекинга задач для ИИ-аудитора"
)
DB_NAME = "/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db"
SECURITY_TOKEN = os.getenv("API_BEARER_TOKEN", "scud_secret_token_2026")
security = HTTPBearer() security = HTTPBearer()
# === ANCHOR: APP_INIT_END ===
def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
# === ANCHOR: DATABASE_HELPERS_START === if credentials.credentials != API_TOKEN:
def init_chat_table(): raise HTTPException(
"""Создает таблицу истории чата, если она не существует.""" status_code=status.HTTP_401_UNAUTHORIZED,
conn = sqlite3.connect(DB_NAME) detail="Неверный токен доступа",
cursor = conn.cursor() headers={"WWW-Authenticate": "Bearer"},
cursor.execute(""" )
CREATE TABLE IF NOT EXISTS chat_sessions (
session_id TEXT PRIMARY KEY,
history_json TEXT NOT NULL,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit()
conn.close()
init_chat_table()
def verify_token(credentials: HTTPAuthorizationCredentials = Security(security)):
if credentials.credentials != SECURITY_TOKEN:
raise HTTPException(status_code=403, detail="Недействительный токен доступа")
return credentials.credentials return credentials.credentials
app = FastAPI(title="SCUD Orion AI Context API")
def get_db_connection():
conn = sqlite3.connect(DB_NAME)
conn.row_factory = sqlite3.Row
return conn
# === ANCHOR: DATABASE_HELPERS_END ===
# === ANCHOR: SCHEMAS_START ===
class TaskSchema(BaseModel):
task_id: str
module: str
title: str
status: str
priority: str
completed_at: Optional[str] = None
class RuleSchema(BaseModel):
id: int
category: str
rule_text: str
is_active: int
class ChatRequest(BaseModel):
session_id: str = "default"
message: str
class ChatResponse(BaseModel):
session_id: str
reply: str
# === ANCHOR: SCHEMAS_END ===
# === ANCHOR: STATIC_ROUTES_START ===
# Подключаем папку static для отдачи HTML-интерфейса
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/", response_class=HTMLResponse) class ChatRequest(BaseModel):
session_id: str
message: str
@app.get("/")
def read_root(): def read_root():
return FileResponse("static/index.html") return FileResponse("static/index.html")
# === ANCHOR: STATIC_ROUTES_END ===
@app.get("/api/v1/tasks")
def get_tasks(token: str = Depends(verify_token)):
return db_get_tasks()
# === ANCHOR: TASKS_ENDPOINTS_START === @app.post("/api/v1/chat")
@app.get("/api/v1/tasks", response_model=List[TaskSchema], dependencies=[Depends(verify_token)]) def chat_endpoint(req: ChatRequest, token: str = Depends(verify_token)):
def get_all_tasks(status: Optional[str] = None): reply, _ = process_chat_message(req.message)
conn = get_db_connection() return {"reply": reply}
cursor = conn.cursor()
if status:
cursor.execute("SELECT * FROM tasks WHERE status = ? ORDER BY task_id", (status.upper(),))
else:
cursor.execute("SELECT * FROM tasks ORDER BY task_id")
rows = cursor.fetchall()
conn.close()
return [dict(r) for r in rows]
# Эндпоинт для гостевого режима (без авторизации и без привязки к проекту)
@app.post("/api/v1/tasks/{task_id}/complete", dependencies=[Depends(verify_token)]) @app.post("/api/v1/chat/guest")
def mark_task_completed(task_id: str): def guest_chat_endpoint(req: ChatRequest):
conn = get_db_connection() payload = {
cursor = conn.cursor() "model": MODEL_NAME,
cursor.execute( "messages": [
"UPDATE tasks SET status = 'COMPLETED', completed_at = CURRENT_TIMESTAMP WHERE task_id = ?", {"role": "system", "content": "Ты — полезный ИИ-ассистент. Отвечай на вопросы пользователя четко и по существу."},
(task_id.upper(),) {"role": "user", "content": req.message}
) ],
if cursor.rowcount == 0: "stream": False,
conn.close() "options": {"num_predict": 2048, "temperature": 0.3}
raise HTTPException(status_code=404, detail=f"Задача {task_id} не найдена") }
conn.commit()
conn.close()
return {"status": "success", "message": f"Задача {task_id} успешно выполнена"}
# === ANCHOR: TASKS_ENDPOINTS_END ===
# === ANCHOR: RULES_AND_CHAT_ENDPOINTS_START ===
@app.get("/api/v1/rules", response_model=List[RuleSchema], dependencies=[Depends(verify_token)])
def get_active_rules():
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT * FROM architecture_memory WHERE is_active = 1 ORDER BY id")
rows = cursor.fetchall()
conn.close()
return [dict(r) for r in rows]
@app.post("/api/v1/chat", response_model=ChatResponse, dependencies=[Depends(verify_token)])
def chat_endpoint(req: ChatRequest):
"""Принимает сообщение, подтягивает историю из БД, выполняет вызовы функций к БД и сохраняет историю."""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT history_json FROM chat_sessions WHERE session_id = ?", (req.session_id,))
row = cursor.fetchone()
chat_history = json.loads(row["history_json"]) if row else [] try:
req_ollama = urllib.request.Request(
reply, updated_history = process_chat_message(req.message, chat_history) OLLAMA_URL,
data=json.dumps(payload).encode("utf-8"),
cursor.execute( headers={"Content-Type": "application/json"}
"INSERT OR REPLACE INTO chat_sessions (session_id, history_json, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", )
(req.session_id, json.dumps(updated_history, ensure_ascii=False)) with urllib.request.urlopen(req_ollama) as response:
) res_data = json.loads(response.read().decode("utf-8"))
conn.commit() reply = res_data.get("message", {}).get("content", "").strip()
conn.close() return {"reply": reply}
except Exception as e:
return ChatResponse(session_id=req.session_id, reply=reply) return {"reply": f"Ошибка связи с локальной нейросетью: {e}"}
@app.get("/health")
def health_check():
return {"status": "healthy", "service": "scud_context_api", "version": "2.0.0"}
# === ANCHOR: RULES_AND_CHAT_ENDPOINTS_END ===
+91 -42
View File
@@ -2,98 +2,147 @@
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>SCUD Orion AI — Context Manager</title> <title>SCUD Orion AI — Context Manager</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/styles.css"> <link rel="stylesheet" href="/static/css/styles.css">
</head> </head>
<body class="bg-slate-100 text-slate-800 h-screen w-full flex flex-col font-sans overflow-hidden"> <body class="bg-slate-100 text-slate-800 h-[100dvh] w-full flex flex-col font-sans overflow-hidden">
<!-- Окно авторизации c гостевым входом -->
<div id="auth-modal" class="fixed inset-0 bg-slate-900/80 backdrop-blur-md z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-2xl p-6 sm:p-8 max-w-md w-full shadow-2xl border border-slate-200">
<div class="flex items-center space-x-3 mb-6">
<div class="bg-indigo-600 text-white p-3 rounded-xl">
<i class="fa-solid fa-lock text-xl"></i>
</div>
<div>
<h2 class="text-lg font-bold text-slate-900">SCUD Orion AI</h2>
<p class="text-xs text-slate-500">Авторизация в системе</p>
</div>
</div>
<form id="auth-form" onsubmit="handleLogin(event)" class="space-y-4">
<div>
<label class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-2">API Token</label>
<input type="password" id="auth-token-input" placeholder="Введите API токен..."
class="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-3 text-sm text-slate-900 focus:outline-none focus:border-indigo-600 focus:bg-white transition">
</div>
<div id="auth-error" class="hidden text-xs text-red-600 font-medium bg-red-50 p-3 rounded-xl border border-red-200">
Неверный токен доступа.
</div>
<button type="submit" id="auth-btn" class="w-full bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white font-semibold py-3 rounded-xl text-sm transition shadow-md flex items-center justify-center gap-2">
<i class="fa-solid fa-right-to-bracket"></i>
<span>Войти с токеном</span>
</button>
</form>
<div class="relative my-5">
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-slate-200"></div></div>
<div class="relative flex justify-center text-xs uppercase"><span class="bg-white px-2 text-slate-400 font-medium">Или</span></div>
</div>
<button onclick="enableGuestMode()" class="w-full bg-slate-100 hover:bg-slate-200 text-slate-700 font-semibold py-3 rounded-xl text-sm transition border border-slate-300 flex items-center justify-center gap-2">
<i class="fa-solid fa-user-ninja"></i>
<span>Войти как гость (Локальный ИИ)</span>
</button>
</div>
</div>
<!-- Хедер --> <!-- Хедер -->
<header class="bg-white border-b border-slate-200 px-3 sm:px-6 py-2.5 sm:py-3 flex justify-between items-center shadow-sm shrink-0"> <header class="bg-white border-b border-slate-200 px-4 py-2.5 flex justify-between items-center shadow-sm shrink-0 z-20">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2.5">
<div class="bg-indigo-600 text-white p-1.5 sm:p-2 rounded-lg shrink-0"> <div class="bg-indigo-600 text-white p-2 rounded-xl shrink-0">
<i class="fa-solid fa-brain text-sm sm:text-lg"></i> <i class="fa-solid fa-brain text-lg"></i>
</div> </div>
<div class="min-w-0"> <div>
<h1 class="text-sm sm:text-base font-bold leading-tight text-slate-900 truncate">SCUD Orion AI</h1> <h1 class="text-sm font-bold text-slate-900 leading-tight">SCUD Orion AI</h1>
<span class="hidden sm:inline text-xs text-slate-500">Context & Task Manager</span> <span class="text-[11px] text-slate-500">Task & Context API</span>
</div> </div>
</div> </div>
<div class="flex items-center space-x-2 shrink-0"> <div class="flex items-center space-x-2">
<span class="flex items-center text-[10px] sm:text-xs text-emerald-600 font-medium bg-emerald-50 px-2 py-1 rounded-full border border-emerald-200"> <span id="guest-badge" class="hidden text-[10px] text-amber-700 font-semibold bg-amber-50 px-2 py-0.5 rounded-full border border-amber-200">
<span class="h-1.5 w-1.5 rounded-full bg-emerald-500 mr-1"></span> API Online Гостевой режим
</span> </span>
<button onclick="toggleDrawer()" class="bg-indigo-50 hover:bg-indigo-100 text-indigo-700 px-2.5 py-1.5 rounded-lg text-xs font-semibold flex items-center gap-1.5 transition border border-indigo-200">
<i class="fa-solid fa-list-check text-indigo-600"></i> <button id="tasks-drawer-btn" onclick="toggleDrawer()" class="bg-indigo-600 active:bg-indigo-700 text-white px-3 py-1.5 rounded-xl text-xs font-semibold flex items-center gap-1.5 shadow-sm">
<span class="hidden sm:inline">Задачи</span> <i class="fa-solid fa-list-check"></i>
<span id="task-count-badge" class="bg-indigo-600 text-white text-[10px] px-1.5 py-0.2 rounded-full">0</span> <span>Задачи</span>
<span id="task-count-badge" class="bg-white text-indigo-700 text-[10px] font-bold px-1.5 py-0.2 rounded-full">0</span>
</button>
<button onclick="logout()" class="text-slate-400 hover:text-red-600 transition p-2 rounded-xl" title="Выйти">
<i class="fa-solid fa-right-from-bracket text-base"></i>
</button> </button>
</div> </div>
</header> </header>
<!-- Основная зона чата --> <!-- Главный контейнер -->
<main class="flex-1 flex flex-col w-full max-w-4xl mx-auto bg-white sm:my-3 sm:rounded-xl border-y sm:border border-slate-200 shadow-sm overflow-hidden min-h-0"> <div class="flex-1 flex flex-col min-h-0 w-full max-w-4xl mx-auto bg-white relative overflow-hidden">
<div id="chat-window" class="flex-1 p-3 sm:p-5 overflow-y-auto space-y-3 bg-slate-50/50"> <div id="chat-window" class="flex-1 p-3.5 overflow-y-auto space-y-3 bg-slate-50/50">
<div class="bg-white border border-slate-200 rounded-xl p-3 sm:p-4 max-w-2xl shadow-sm"> <div class="bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm">
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1"> <p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
<i class="fa-solid fa-robot mr-1"></i> ИИ-Ассистент <i class="fa-solid fa-robot mr-1"></i> ИИ-Ассистент
</p> </p>
<p class="text-slate-700 text-xs sm:text-sm leading-relaxed"> <p class="text-slate-800 text-xs sm:text-sm leading-relaxed">
Привет! Я подключен к вашей базе задач и системным промптам. Вы можете писать команды прямо в чат или управлять задачами через панель задач. Привет! Задавайте вопросы нейросети прямо в чат.
</p> </p>
</div> </div>
</div> </div>
<!-- Поле ввода --> <!-- Поле ввода -->
<div class="p-2 sm:p-3 bg-white border-t border-slate-200 shrink-0"> <div class="p-2.5 pb-6 bg-white border-t border-slate-200 shrink-0 z-10 shadow-lg">
<form id="chat-form" onsubmit="sendMessage(event)" class="flex items-end gap-1.5 sm:gap-2"> <form id="chat-form" onsubmit="sendMessage(event)" class="flex items-center gap-2">
<div class="flex-1 bg-slate-50 border border-slate-300 rounded-xl p-1 focus-within:border-indigo-600 focus-within:bg-white transition"> <div class="flex-1 bg-slate-100 border border-slate-300 rounded-2xl px-3 py-1.5 focus-within:border-indigo-600 focus-within:bg-white transition">
<textarea id="user-input" rows="1" autocomplete="off" autocorrect="off" spellcheck="false" <textarea id="user-input" rows="1" autocomplete="off" autocorrect="off" spellcheck="false"
placeholder="Команда или вопрос..." placeholder="Команда или вопрос..."
class="w-full bg-transparent text-slate-800 px-2.5 py-1.5 text-sm sm:text-base focus:outline-none resize-none overflow-y-auto max-h-[96px] leading-relaxed fade-scroll-top no-scrollbar"></textarea> class="w-full bg-transparent text-slate-900 text-sm focus:outline-none resize-none overflow-y-auto max-h-[80px] leading-normal no-scrollbar"></textarea>
</div> </div>
<button type="submit" id="send-btn" class="bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white font-medium px-4 h-[42px] rounded-xl text-sm transition flex items-center justify-center gap-1.5 shrink-0"> <button type="submit" id="send-btn" class="bg-indigo-600 active:bg-indigo-800 text-white font-semibold px-3.5 py-2.5 rounded-2xl text-xs sm:text-sm transition flex items-center justify-center gap-1.5 shrink-0 shadow-sm">
<span class="hidden sm:inline">Отправить</span> <span>Отправить</span>
<i class="fa-solid fa-paper-plane text-xs"></i> <i class="fa-solid fa-paper-plane text-xs"></i>
</button> </button>
</form> </form>
</div> </div>
</main> </div>
<!-- Выезжающая панель (Drawer) --> <!-- Выезжающая панель (Drawer) -->
<div id="drawer-backdrop" onclick="toggleDrawer()" class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm hidden transition-opacity z-40"></div> <div id="drawer-backdrop" onclick="toggleDrawer()" class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm hidden transition-opacity z-40"></div>
<aside id="task-drawer" class="fixed right-0 top-0 h-full w-full sm:w-[420px] bg-white border-l border-slate-200 shadow-2xl transform translate-x-full transition-transform duration-300 ease-in-out z-50 flex flex-col"> <aside id="task-drawer" class="fixed right-0 top-0 h-full w-full sm:w-[420px] bg-white border-l border-slate-200 shadow-2xl transform translate-x-full transition-transform duration-300 ease-in-out z-50 flex flex-col">
<div class="p-3 sm:p-4 border-b border-slate-200 flex justify-between items-center bg-slate-50 shrink-0"> <div class="p-3.5 border-b border-slate-200 flex justify-between items-center bg-slate-50 shrink-0">
<h2 class="font-bold text-slate-800 flex items-center gap-2 text-sm"> <h2 class="font-bold text-slate-800 flex items-center gap-2 text-sm">
<i class="fa-solid fa-list-check text-indigo-600"></i> Реестр задач <i class="fa-solid fa-list-check text-indigo-600"></i> Реестр задач
</h2> </h2>
<div class="flex items-center gap-2"> <div class="flex items-center gap-3">
<button onclick="loadTasks()" class="text-xs text-slate-500 hover:text-indigo-600 transition p-2" title="Обновить"> <button onclick="loadTasks()" class="text-xs text-slate-500 hover:text-indigo-600 transition p-1" title="Обновить">
<i class="fa-solid fa-rotate-right"></i> <i class="fa-solid fa-rotate-right text-sm"></i>
</button> </button>
<button onclick="toggleDrawer()" class="text-slate-400 hover:text-slate-700 transition p-2"> <button onclick="toggleDrawer()" class="text-slate-500 hover:text-slate-800 transition p-1">
<i class="fa-solid fa-xmark text-lg"></i> <i class="fa-solid fa-xmark text-lg"></i>
</button> </button>
</div> </div>
</div> </div>
<!-- Фильтры задач --> <div class="flex border-b border-slate-200 bg-white px-2 pt-2 text-xs font-semibold text-slate-500 gap-1 overflow-x-auto no-scrollbar shrink-0">
<div class="flex border-b border-slate-200 bg-white px-2 pt-2 text-xs font-medium text-slate-500 gap-1 overflow-x-auto no-scrollbar shrink-0">
<button onclick="setFilter('ALL')" id="filter-ALL" class="px-3 py-1.5 rounded-t-lg border-b-2 border-indigo-600 text-indigo-600 font-bold whitespace-nowrap">Все</button> <button onclick="setFilter('ALL')" id="filter-ALL" class="px-3 py-1.5 rounded-t-lg border-b-2 border-indigo-600 text-indigo-600 font-bold whitespace-nowrap">Все</button>
<button onclick="setFilter('IN_PROGRESS')" id="filter-IN_PROGRESS" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent hover:text-slate-700 whitespace-nowrap">В работе</button> <button onclick="setFilter('IN_PROGRESS')" id="filter-IN_PROGRESS" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap">В работе</button>
<button onclick="setFilter('BACKLOG')" id="filter-BACKLOG" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent hover:text-slate-700 whitespace-nowrap">Бэклог</button> <button onclick="setFilter('BACKLOG')" id="filter-BACKLOG" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap">Бэклог</button>
<button onclick="setFilter('COMPLETED')" id="filter-COMPLETED" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent hover:text-slate-700 whitespace-nowrap">Завершено</button> <button onclick="setFilter('COMPLETED')" id="filter-COMPLETED" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap">Завершено</button>
</div> </div>
<div id="tasks-container" class="flex-1 overflow-y-auto p-3.5 sm:p-4 space-y-3 bg-slate-50/50"> <div id="tasks-container" class="flex-1 overflow-y-auto p-3.5 space-y-3 bg-slate-50/50 pb-8">
<div class="text-center text-slate-400 py-8 text-xs">Загрузка задач...</div> <div class="text-center text-slate-400 py-8 text-xs">Загрузка задач...</div>
</div> </div>
</aside> </aside>
<!-- Порядок подключения модулей JS -->
<script src="/static/js/auth.js"></script>
<script src="/static/js/tasks.js"></script>
<script src="/static/js/chat.js"></script>
<script src="/static/js/app.js"></script> <script src="/static/js/app.js"></script>
</body> </body>
</html> </html>
+19 -162
View File
@@ -1,11 +1,12 @@
const API_TOKEN = "scud_secret_token_2026"; const AUTH_TOKEN_KEY = "scud_api_auth_token";
const SESSION_ID = "web_session_main"; const SESSION_ID = "web_session_main";
const STORAGE_KEY = 'scud_chat_input_history'; const STORAGE_KEY = 'scud_chat_input_history';
let API_TOKEN = localStorage.getItem(AUTH_TOKEN_KEY) || "";
let IS_GUEST = localStorage.getItem('scud_is_guest') === 'true';
let currentFilter = 'ALL'; let currentFilter = 'ALL';
let allTasks = []; let allTasks = [];
// === ИСТОРИЯ КОМАНД (LOCALSTORAGE + СТРЕЛКИ ВВЕРХ/ВНИЗ) ===
let inputHistory = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); let inputHistory = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
let historyIndex = -1; let historyIndex = -1;
@@ -13,10 +14,9 @@ document.addEventListener('DOMContentLoaded', () => {
const userInputEl = document.getElementById('user-input'); const userInputEl = document.getElementById('user-input');
if (userInputEl) { if (userInputEl) {
// Динамическое расширение высоты поля до 4 строк (~96px)
userInputEl.addEventListener('input', function() { userInputEl.addEventListener('input', function() {
this.style.height = 'auto'; this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 96) + 'px'; this.style.height = Math.min(this.scrollHeight, 80) + 'px';
}); });
userInputEl.addEventListener('keydown', function(e) { userInputEl.addEventListener('keydown', function(e) {
@@ -53,163 +53,20 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
loadTasks(); if (IS_GUEST) {
}); hideAuthModal();
updateUIState();
function toggleDrawer() { } else if (API_TOKEN) {
const drawer = document.getElementById('task-drawer'); verifyToken(API_TOKEN).then(isValid => {
const backdrop = document.getElementById('drawer-backdrop'); if (isValid) {
const isHidden = drawer.classList.contains('translate-x-full'); hideAuthModal();
if (isHidden) { updateUIState();
drawer.classList.remove('translate-x-full'); loadTasks();
backdrop.classList.remove('hidden'); } else {
showAuthModal();
}
});
} else { } else {
drawer.classList.add('translate-x-full'); showAuthModal();
backdrop.classList.add('hidden');
} }
} });
function setFilter(status) {
currentFilter = status;
['ALL', 'IN_PROGRESS', 'BACKLOG', 'COMPLETED'].forEach(f => {
const btn = document.getElementById(`filter-${f}`);
if (f === status) {
btn.className = "px-3 py-1.5 rounded-t-lg border-b-2 border-indigo-600 text-indigo-600 font-bold";
} else {
btn.className = "px-3 py-1.5 rounded-t-lg border-b-2 border-transparent hover:text-slate-700";
}
});
renderTasks();
}
async function loadTasks() {
try {
const res = await fetch('/api/v1/tasks', {
headers: { 'Authorization': `Bearer ${API_TOKEN}` }
});
allTasks = await res.json();
document.getElementById('task-count-badge').innerText = allTasks.length;
renderTasks();
} catch (err) {
document.getElementById('tasks-container').innerHTML = `<div class="text-red-500 text-xs py-4 text-center">Ошибка загрузки задач</div>`;
}
}
function renderTasks() {
const container = document.getElementById('tasks-container');
const filtered = allTasks.filter(t => currentFilter === 'ALL' || t.status === currentFilter);
if (filtered.length === 0) {
container.innerHTML = `<div class="text-slate-400 text-xs py-8 text-center">Нет задач с выбранным фильтром</div>`;
return;
}
container.innerHTML = filtered.map(t => {
let statusBadge = 'bg-slate-100 text-slate-600 border-slate-200';
let cardBg = 'bg-white';
if (t.status === 'COMPLETED') {
statusBadge = 'bg-emerald-50 text-emerald-700 border-emerald-300 font-semibold';
cardBg = 'bg-emerald-50/20';
} else if (t.status === 'IN_PROGRESS') {
statusBadge = 'bg-amber-50 text-amber-700 border-amber-300 font-bold';
cardBg = 'bg-amber-50/20 border-amber-200';
}
let priorityBadge = 'text-slate-500 bg-slate-100 border-slate-200';
if (t.priority === 'HIGH') priorityBadge = 'text-red-700 bg-red-50 border-red-200 font-bold';
let dueDateHtml = '';
if (t.due_date) {
dueDateHtml = `
<div class="mt-2 text-[11px] text-amber-800 bg-amber-50 border border-amber-200 px-2 py-0.5 rounded-md flex items-center gap-1.5 w-fit font-medium">
<i class="fa-solid fa-clock text-amber-600"></i>
<span>Срок: ${t.due_date}</span>
</div>
`;
}
return `
<div class="${cardBg} border border-slate-200 rounded-xl p-3.5 shadow-sm hover:shadow-md transition">
<div class="flex justify-between items-center mb-1.5">
<div class="flex items-center gap-1.5">
<span class="font-mono text-xs font-bold text-slate-900 bg-slate-100 px-2 py-0.5 rounded border border-slate-200">${t.task_id}</span>
<span class="text-[10px] uppercase px-1.5 py-0.5 rounded border ${priorityBadge}">${t.priority || 'HIGH'}</span>
</div>
<span class="text-[10px] uppercase px-2 py-0.5 rounded border ${statusBadge}">${t.status}</span>
</div>
<h3 class="text-xs font-semibold text-slate-800 mb-1 leading-snug">${t.title}</h3>
<div class="text-[10px] text-slate-400 font-mono flex items-center gap-1">
<i class="fa-solid fa-folder-closed text-slate-300"></i>
<span>${t.module}</span>
</div>
${dueDateHtml}
</div>
`;
}).join('');
}
async function sendMessage(e) {
e.preventDefault();
const input = document.getElementById('user-input');
const chatWindow = document.getElementById('chat-window');
const sendBtn = document.getElementById('send-btn');
const text = input.value.trim();
if (!text) return;
if (inputHistory[inputHistory.length - 1] !== text) {
inputHistory.push(text);
if (inputHistory.length > 50) inputHistory.shift();
localStorage.setItem(STORAGE_KEY, JSON.stringify(inputHistory));
}
historyIndex = -1;
chatWindow.innerHTML += `
<div class="flex justify-end">
<div class="bg-indigo-600 text-white rounded-xl px-4 py-2.5 max-w-2xl text-sm shadow-sm">
${text}
</div>
</div>
`;
input.value = '';
input.style.height = 'auto';
chatWindow.scrollTop = chatWindow.scrollHeight;
sendBtn.disabled = true;
sendBtn.classList.add('opacity-50');
try {
const res = await fetch('/api/v1/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_TOKEN}`
},
body: JSON.stringify({ session_id: SESSION_ID, message: text })
});
const data = await res.json();
chatWindow.innerHTML += `
<div class="bg-white border border-slate-200 rounded-xl p-4 max-w-2xl shadow-sm">
<p class="text-xs font-bold text-indigo-600 uppercase tracking-wider mb-1"><i class="fa-solid fa-robot mr-1"></i> ИИ-Ассистент</p>
<p class="text-slate-700 text-sm whitespace-pre-wrap leading-relaxed">${data.reply}</p>
</div>
`;
chatWindow.scrollTop = chatWindow.scrollHeight;
loadTasks();
} catch (err) {
chatWindow.innerHTML += `
<div class="bg-red-50 border border-red-200 rounded-xl p-4 max-w-2xl text-red-700 text-sm">
Ошибка связи с сервером API.
</div>
`;
} finally {
sendBtn.disabled = false;
sendBtn.classList.remove('opacity-50');
}
}
+72
View File
@@ -0,0 +1,72 @@
function showAuthModal() {
document.getElementById('auth-modal').classList.remove('hidden');
}
function hideAuthModal() {
document.getElementById('auth-modal').classList.add('hidden');
}
async function verifyToken(token) {
try {
const res = await fetch('/api/v1/tasks', {
headers: { 'Authorization': `Bearer ${token}` }
});
return res.status === 200;
} catch (err) {
return false;
}
}
async function handleLogin(e) {
e.preventDefault();
const tokenInput = document.getElementById('auth-token-input');
const errorEl = document.getElementById('auth-error');
const token = tokenInput.value.trim();
if (!token) return;
errorEl.classList.add('hidden');
const isValid = await verifyToken(token);
if (isValid) {
API_TOKEN = token;
IS_GUEST = false;
localStorage.setItem(AUTH_TOKEN_KEY, token);
localStorage.removeItem('scud_is_guest');
hideAuthModal();
updateUIState();
loadTasks();
} else {
errorEl.classList.remove('hidden');
}
}
function enableGuestMode() {
IS_GUEST = true;
API_TOKEN = "";
localStorage.setItem('scud_is_guest', 'true');
hideAuthModal();
updateUIState();
}
function logout() {
localStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem('scud_is_guest');
API_TOKEN = "";
IS_GUEST = false;
document.getElementById('auth-token-input').value = "";
showAuthModal();
}
function updateUIState() {
const tasksBtn = document.getElementById('tasks-drawer-btn');
const guestBadge = document.getElementById('guest-badge');
if (IS_GUEST) {
if (tasksBtn) tasksBtn.classList.add('hidden');
if (guestBadge) guestBadge.classList.remove('hidden');
} else {
if (tasksBtn) tasksBtn.classList.remove('hidden');
if (guestBadge) guestBadge.classList.add('hidden');
}
}
+71
View File
@@ -0,0 +1,71 @@
async function sendMessage(e) {
e.preventDefault();
const input = document.getElementById('user-input');
const chatWindow = document.getElementById('chat-window');
const sendBtn = document.getElementById('send-btn');
const text = input.value.trim();
if (!text) return;
if (inputHistory[inputHistory.length - 1] !== text) {
inputHistory.push(text);
if (inputHistory.length > 50) inputHistory.shift();
localStorage.setItem(STORAGE_KEY, JSON.stringify(inputHistory));
}
historyIndex = -1;
chatWindow.innerHTML += `
<div class="flex justify-end">
<div class="bg-indigo-600 text-white rounded-2xl px-4 py-2.5 max-w-2xl text-xs sm:text-sm shadow-sm">
${text}
</div>
</div>
`;
input.value = '';
input.style.height = 'auto';
chatWindow.scrollTop = chatWindow.scrollHeight;
sendBtn.disabled = true;
sendBtn.classList.add('opacity-50');
const endpoint = IS_GUEST ? '/api/v1/chat/guest' : '/api/v1/chat';
const headers = { 'Content-Type': 'application/json' };
if (!IS_GUEST) {
headers['Authorization'] = `Bearer ${API_TOKEN}`;
}
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: headers,
body: JSON.stringify({ session_id: SESSION_ID, message: text })
});
if (res.status === 401 && !IS_GUEST) {
logout();
return;
}
const data = await res.json();
const assistantTitle = IS_GUEST ? "Локальная нейросеть (Гость)" : "ИИ-Ассистент SCUD Orion AI";
chatWindow.innerHTML += `
<div class="bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm max-w-2xl">
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1"><i class="fa-solid fa-robot mr-1"></i> ${assistantTitle}</p>
<p class="text-slate-800 text-xs sm:text-sm whitespace-pre-wrap leading-relaxed">${data.reply}</p>
</div>
`;
chatWindow.scrollTop = chatWindow.scrollHeight;
if (!IS_GUEST) loadTasks();
} catch (err) {
chatWindow.innerHTML += `
<div class="bg-red-50 border border-red-200 rounded-2xl p-3.5 max-w-2xl text-red-700 text-xs sm:text-sm">
Ошибка связи с сервером.
</div>
`;
} finally {
sendBtn.disabled = false;
sendBtn.classList.remove('opacity-50');
}
}
+93
View File
@@ -0,0 +1,93 @@
function toggleDrawer() {
if (IS_GUEST) return;
const drawer = document.getElementById('task-drawer');
const backdrop = document.getElementById('drawer-backdrop');
const isHidden = drawer.classList.contains('translate-x-full');
if (isHidden) {
drawer.classList.remove('translate-x-full');
backdrop.classList.remove('hidden');
} else {
drawer.classList.add('translate-x-full');
backdrop.classList.add('hidden');
}
}
function setFilter(status) {
currentFilter = status;
['ALL', 'IN_PROGRESS', 'BACKLOG', 'COMPLETED'].forEach(f => {
const btn = document.getElementById(`filter-${f}`);
if (btn) {
btn.className = (f === status)
? "px-3 py-1.5 rounded-t-lg border-b-2 border-indigo-600 text-indigo-600 font-bold whitespace-nowrap"
: "px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap";
}
});
renderTasks();
}
async function loadTasks() {
if (IS_GUEST || !API_TOKEN) return;
try {
const res = await fetch('/api/v1/tasks', {
headers: { 'Authorization': `Bearer ${API_TOKEN}` }
});
if (res.status === 401) {
logout();
return;
}
allTasks = await res.json();
document.getElementById('task-count-badge').innerText = allTasks.length;
renderTasks();
} catch (err) {
document.getElementById('tasks-container').innerHTML = `<div class="text-red-500 text-xs py-4 text-center">Ошибка загрузки задач</div>`;
}
}
function renderTasks() {
const container = document.getElementById('tasks-container');
const filtered = allTasks.filter(t => currentFilter === 'ALL' || t.status === currentFilter);
if (filtered.length === 0) {
container.innerHTML = `<div class="text-slate-400 text-xs py-8 text-center">Нет задач с выбранным фильтром</div>`;
return;
}
container.innerHTML = filtered.map(t => {
let statusBadge = 'bg-slate-100 text-slate-600 border-slate-200';
let cardBg = 'bg-white';
if (t.status === 'COMPLETED') {
statusBadge = 'bg-emerald-50 text-emerald-700 border-emerald-300 font-semibold';
cardBg = 'bg-emerald-50/20';
} else if (t.status === 'IN_PROGRESS') {
statusBadge = 'bg-amber-50 text-amber-700 border-amber-300 font-bold';
cardBg = 'bg-amber-50/20 border-amber-200';
}
let priorityBadge = 'text-slate-500 bg-slate-100 border-slate-200';
if (t.priority === 'HIGH') priorityBadge = 'text-red-700 bg-red-50 border-red-200 font-bold';
let dueDateHtml = t.due_date ? `
<div class="mt-2 text-[11px] text-amber-800 bg-amber-50 border border-amber-200 px-2 py-0.5 rounded-md flex items-center gap-1.5 w-fit font-medium">
<i class="fa-solid fa-clock text-amber-600"></i>
<span>Срок: ${t.due_date}</span>
</div>` : '';
return `
<div class="${cardBg} border border-slate-200 rounded-xl p-3.5 shadow-sm hover:shadow-md transition">
<div class="flex justify-between items-center mb-1.5">
<div class="flex items-center gap-1.5">
<span class="font-mono text-xs font-bold text-slate-900 bg-slate-100 px-2 py-0.5 rounded border border-slate-200">${t.task_id}</span>
<span class="text-[10px] uppercase px-1.5 py-0.5 rounded border ${priorityBadge}">${t.priority || 'HIGH'}</span>
</div>
<span class="text-[10px] uppercase px-2 py-0.5 rounded border ${statusBadge}">${t.status}</span>
</div>
<h3 class="text-xs font-semibold text-slate-800 mb-1 leading-snug">${t.title}</h3>
<div class="text-[10px] text-slate-400 font-mono flex items-center gap-1">
<i class="fa-solid fa-folder-closed text-slate-300"></i>
<span>${t.module}</span>
</div>
${dueDateHtml}
</div>
`;
}).join('');
}