налажена логика добавления промптов через диалог с ИИ, отлажена работа с задачами, настроена авторизация в веб интерфейсе.

This commit is contained in:
2026-08-09 13:33:55 +03:00
parent 8a3f3c749f
commit 2fa151345f
9 changed files with 842 additions and 280 deletions
+59 -63
View File
@@ -18,41 +18,22 @@ from .schemas import TOOLS_SCHEMA
OLLAMA_URL = "http://192.168.11.3:11434/api/chat" OLLAMA_URL = "http://192.168.11.3:11434/api/chat"
MODEL_NAME = "qwen2.5:14b" MODEL_NAME = "qwen2.5:14b"
def format_rules_output(rules: List[Dict[str, Any]]) -> str:
if not rules:
return "База знаний пока пуста."
lines = [f"📚 База знаний и правила арбитража ({len(rules)}):\n"]
for idx, r in enumerate(rules, 1):
rule_text = r.get("rule_text", "").strip()
lines.append(f"{idx}. {rule_text}\n")
return "\n".join(lines).strip()
def process_chat_message(user_id: int, user_message: str, chat_history: List[Dict[str, Any]] = None) -> Tuple[str, List[Dict[str, Any]]]: def process_chat_message(user_id: int, user_message: str, chat_history: List[Dict[str, Any]] = None) -> Tuple[str, List[Dict[str, Any]]]:
if chat_history is None: if chat_history is None:
chat_history = [] chat_history = []
current_now = datetime.now().strftime("%Y-%m-%d %H:%M") current_now = datetime.now().strftime("%Y-%m-%d %H:%M")
user_lower = user_message.lower().strip()
# Быстрый прямой ответ для списка задач текущего пользователя
if any(phrase in user_lower for phrase in ["все задачи", "покажи задачи", "список задач", "реестр задач"]):
tasks = db_get_tasks(user_id)
if not tasks:
formatted_text = "Ваш список задач пуст."
else:
def get_task_num(t):
tid = str(t.get("task_id", ""))
try:
return int(tid.upper().replace("TASK-", "").strip())
except ValueError:
return 0
tasks_sorted = sorted(tasks, key=get_task_num, reverse=True)
lines = [f"📋 Ваш реестр задач ({len(tasks_sorted)}):\n"]
for t in tasks_sorted:
tid = t.get('task_id', '')
title = t.get('title', 'Без названия')
lines.append(f"• {tid}: {title}")
formatted_text = "\n".join(lines)
return formatted_text, chat_history + [{"role": "user", "content": user_message}, {"role": "assistant", "content": formatted_text}]
if any(phrase in user_lower for phrase in ["системный промпт", "покажи промпт", "промпт системы"]):
prompt_text = db_get_active_system_prompt()
formatted_text = f"⚙️ Актуальный системный промпт ИИ:\n\n{prompt_text}"
return formatted_text, chat_history + [{"role": "user", "content": user_message}, {"role": "assistant", "content": formatted_text}]
# Запрос к локальной модели Qwen (полный цикл Function Calling)
dynamic_prompt_text = db_get_active_system_prompt() dynamic_prompt_text = db_get_active_system_prompt()
system_prompt = { system_prompt = {
"role": "system", "role": "system",
@@ -69,63 +50,78 @@ def process_chat_message(user_id: int, user_message: str, chat_history: List[Dic
"options": {"num_predict": 2048, "num_ctx": 8192, "temperature": 0.1} "options": {"num_predict": 2048, "num_ctx": 8192, "temperature": 0.1}
} }
req = urllib.request.Request(
OLLAMA_URL,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"}
)
try: try:
req = urllib.request.Request(
OLLAMA_URL,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req) as response: with urllib.request.urlopen(req) as response:
res_data = json.loads(response.read().decode("utf-8")) res_data = json.loads(response.read().decode("utf-8"))
msg = res_data.get("message", {}) msg = res_data.get("message", {})
tool_calls = msg.get("tool_calls", []) tool_calls = msg.get("tool_calls", [])
content_str = msg.get("content", "").strip().replace("**", "")
if tool_calls: if tool_calls:
messages.append(msg)
for tool in tool_calls: for tool in tool_calls:
fn_name = tool["function"]["name"] fn_name = tool["function"]["name"]
fn_args = tool["function"].get("arguments", {}) fn_args = tool["function"].get("arguments", {})
tool_result_content = ""
if fn_name == "db_get_tasks": if fn_name == "db_get_tasks":
tasks = db_get_tasks(user_id) tasks = db_get_tasks(user_id)
if not tasks: tool_result_content = json.dumps(tasks, ensure_ascii=False)
formatted_text = "Ваш список задач пуст."
else:
def get_task_num(t):
tid = str(t.get("task_id", ""))
try:
return int(tid.upper().replace("TASK-", "").strip())
except ValueError:
return 0
tasks_sorted = sorted(tasks, key=get_task_num, reverse=True) elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]:
lines = [f"📋 Ваш реестр задач ({len(tasks_sorted)}):\n"] tool_result_content = db_get_active_system_prompt()
for t in tasks_sorted:
tid = t.get('task_id', '')
title = t.get('title', 'Без названия')
lines.append(f"• {tid}: {title}")
formatted_text = "\n".join(lines)
return formatted_text, chat_history + [{"role": "user", "content": user_message}, {"role": "assistant", "content": formatted_text}] elif fn_name == "db_add_system_prompt":
res = db_add_system_prompt(
name=fn_args.get("name", "main_agent"),
prompt_text=fn_args.get("prompt_text")
)
tool_result_content = json.dumps(res, ensure_ascii=False)
elif fn_name == "db_get_rules":
tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False)
elif fn_name == "db_add_task": elif fn_name == "db_add_task":
res = db_add_task(user_id=user_id, module=fn_args.get("module", "general"), title=fn_args.get("title"), priority=fn_args.get("priority", "MEDIUM"), due_date=fn_args.get("due_date")) res = db_add_task(user_id=user_id, module=fn_args.get("module", "general"), title=fn_args.get("title"), priority=fn_args.get("priority", "MEDIUM"), due_date=fn_args.get("due_date"))
formatted_text = f"[✓] Задача {res.get('task_id', 'TASK')} успешно создана!" if "status" in res else f"❌ Ошибка: {res.get('error')}" tool_result_content = json.dumps(res, ensure_ascii=False)
return formatted_text, chat_history + [{"role": "user", "content": user_message}, {"role": "assistant", "content": formatted_text}]
elif fn_name == "db_update_task_status": elif fn_name == "db_update_task_status":
res = db_update_task_status(user_id=user_id, task_id=fn_args.get("task_id"), status=fn_args.get("status"), due_date=fn_args.get("due_date")) res = db_update_task_status(user_id=user_id, task_id=str(fn_args.get("task_id")), status=fn_args.get("status", "COMPLETED"), due_date=fn_args.get("due_date"))
formatted_text = f"[✓] Статус задачи {fn_args.get('task_id')} обновлен!" if "status" in res else f"❌ Ошибка: {res.get('error')}" tool_result_content = json.dumps(res, ensure_ascii=False)
return formatted_text, chat_history + [{"role": "user", "content": user_message}, {"role": "assistant", "content": formatted_text}]
elif fn_name == "db_delete_task": elif fn_name == "db_delete_task":
task_id_to_del = fn_args.get("task_id", "").upper() res = db_delete_task(user_id=user_id, task_id=str(fn_args.get("task_id", "")).upper())
res = db_delete_task(user_id=user_id, task_id=task_id_to_del) tool_result_content = json.dumps(res, ensure_ascii=False)
formatted_text = f"[✓] Задача {task_id_to_del} успешно удалена из вашей базы!" if "status" in res else f"❌ Ошибка: {res.get('error')}"
return formatted_text, chat_history + [{"role": "user", "content": user_message}, {"role": "assistant", "content": formatted_text}]
return content_str, chat_history + [{"role": "user", "content": user_message}, {"role": "assistant", "content": content_str}] messages.append({
"role": "tool",
"content": tool_result_content
})
# Вторичный запрос модели для формирования итогового ответа оператору
second_payload = {
"model": MODEL_NAME,
"messages": messages,
"stream": False,
"options": {"num_predict": 2048, "num_ctx": 8192, "temperature": 0.1}
}
sec_req = urllib.request.Request(
OLLAMA_URL,
data=json.dumps(second_payload).encode("utf-8"),
headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(sec_req) as sec_response:
sec_res_data = json.loads(sec_response.read().decode("utf-8"))
final_content = sec_res_data.get("message", {}).get("content", "").strip().replace("**", "")
return final_content, chat_history + [{"role": "user", "content": user_message}, {"role": "assistant", "content": final_content}]
content_str = msg.get("content", "").strip().replace("**", "")
return content_str or "Запрос обработан.", chat_history + [{"role": "user", "content": user_message}, {"role": "assistant", "content": content_str}]
except urllib.error.URLError as e: except urllib.error.URLError as e:
return f"Ошибка связи с Ollama ({OLLAMA_URL}): {e}", chat_history return f"Ошибка связи с Ollama ({OLLAMA_URL}): {e}", chat_history
+42 -15
View File
@@ -8,6 +8,16 @@ def get_db_connection():
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
return conn return conn
def normalize_task_id(task_id_input: str) -> str:
"""Преобразует 6, '6', 'task-6' в красивый формат TASK-06 или TASK-006"""
if not task_id_input:
return ""
clean_id = str(task_id_input).strip().upper().replace("TASK-", "").replace("TASK", "")
if clean_id.isdigit():
num = int(clean_id)
return f"TASK-{num:02d}" if num < 100 else f"TASK-{num:03d}"
return f"TASK-{clean_id}"
# === ЗАДАЧИ С ФИЛЬТРАЦИЕЙ ПО USER_ID === # === ЗАДАЧИ С ФИЛЬТРАЦИЕЙ ПО USER_ID ===
def db_get_tasks(user_id: int) -> List[Dict[str, Any]]: def db_get_tasks(user_id: int) -> List[Dict[str, Any]]:
@@ -40,47 +50,51 @@ def db_add_task(user_id: int, module: str, title: str, priority: str = "MEDIUM",
conn.close() conn.close()
return {"status": "success", "task_id": new_task_id, "message": f"Задача {new_task_id} создана"} return {"status": "success", "task_id": new_task_id, "message": f"Задача {new_task_id} создана"}
def db_update_task_status(user_id: int, task_id: str, status: str, due_date: Optional[str] = None) -> Dict[str, Any]: def db_update_task_status(user_id: int, task_id: str, status: str = "COMPLETED", due_date: Optional[str] = None) -> Dict[str, Any]:
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
task_id_upper = task_id.upper().strip() formatted_id = normalize_task_id(task_id)
if due_date: if due_date:
cursor.execute(""" cursor.execute("""
UPDATE tasks UPDATE tasks
SET status = ?, due_date = ? SET status = ?, due_date = ?
WHERE UPPER(task_id) = ? AND user_id = ? WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
""", (status.upper(), due_date, task_id_upper, user_id)) """, (status.upper(), due_date, formatted_id, f"%{task_id.strip()}", user_id))
else: else:
cursor.execute(""" cursor.execute("""
UPDATE tasks UPDATE tasks
SET status = ? SET status = ?
WHERE UPPER(task_id) = ? AND user_id = ? WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
""", (status.upper(), task_id_upper, user_id)) """, (status.upper(), formatted_id, f"%{task_id.strip()}", user_id))
if cursor.rowcount == 0: if cursor.rowcount == 0:
conn.close() conn.close()
return {"error": f"Задача {task_id_upper} не найдена или принадлежит другому пользователю"} return {"error": f"Задача {task_id} не найдена или принадлежит другому пользователю"}
conn.commit() conn.commit()
conn.close() conn.close()
return {"status": "success", "message": f"Статус {task_id_upper} обновлен"} return {"status": "success", "message": f"Статус задачи {formatted_id} обновлен на {status.upper()}"}
def db_delete_task(user_id: int, task_id: str) -> Dict[str, Any]: def db_delete_task(user_id: int, task_id: str) -> Dict[str, Any]:
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
task_id_upper = task_id.upper().strip()
cursor.execute("DELETE FROM tasks WHERE UPPER(task_id) = ? AND user_id = ?", (task_id_upper, user_id)) formatted_id = normalize_task_id(task_id)
cursor.execute("""
DELETE FROM tasks
WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
""", (formatted_id, f"%{task_id.strip()}", user_id))
if cursor.rowcount == 0: if cursor.rowcount == 0:
conn.close() conn.close()
return {"error": f"Задача {task_id_upper} не найдена или принадлежит другому пользователю"} return {"error": f"Задача {task_id} не найдена"}
conn.commit() conn.commit()
conn.close() conn.close()
return {"status": "success", "message": f"Задача {task_id_upper} удалена"} return {"status": "success", "message": f"Задача {formatted_id} удалена"}
# === ОБЩИЕ СИСТЕМНЫЕ РЕСУРСЫ === # === ОБЩИЕ СИСТЕМНЫЕ РЕСУРСЫ ===
@@ -95,11 +109,24 @@ def db_get_active_system_prompt() -> str:
def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]: def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]:
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("UPDATE system_prompts SET is_active = 0")
cursor.execute("INSERT INTO system_prompts (name, prompt_text, is_active) VALUES (?, ?, 1)", (name, prompt_text)) # Проверяем наличие промпта с таким именем, чтобы не плодить мусор
cursor.execute("SELECT id FROM system_prompts WHERE name = ?", (name,))
existing = cursor.fetchone()
if existing:
cursor.execute("""
UPDATE system_prompts
SET prompt_text = ?, updated_at = CURRENT_TIMESTAMP, is_active = 1
WHERE name = ?
""", (prompt_text, name))
else:
cursor.execute("UPDATE system_prompts SET is_active = 0")
cursor.execute("INSERT INTO system_prompts (name, prompt_text, is_active) VALUES (?, ?, 1)", (name, prompt_text))
conn.commit() conn.commit()
conn.close() conn.close()
return {"status": "success", "message": "Системный промпт обновлен"} return {"status": "success", "message": "Системный промпт успешно обновлен"}
def db_get_rules() -> List[Dict[str, Any]]: def db_get_rules() -> List[Dict[str, Any]]:
conn = get_db_connection() conn = get_db_connection()
+2 -2
View File
@@ -35,11 +35,11 @@ TOOLS_SCHEMA = [
"type": "function", "type": "function",
"function": { "function": {
"name": "db_add_system_prompt", "name": "db_add_system_prompt",
"description": "Обновить или добавить системный промпт ИИ в базу данных. Вызывай при командах 'задай системный промпт', 'измени промпт', 'обнови системный промпт'.", "description": "Изменить, записать или добавить новый системный промпт ассистента в базу данных. Вызывай этот инструмент ВСЕГДА, когда пользователь просит 'добавь системный промпт', 'запиши промпт', 'измени инструкции ИИ'.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"prompt_text": {"type": "string", "description": "Полный новый текст системного промпта"}, "prompt_text": {"type": "string", "description": "Полный текст нового системного промпта"},
"name": {"type": "string", "description": "Имя промпта, по умолчанию main_agent"} "name": {"type": "string", "description": "Имя промпта, по умолчанию main_agent"}
}, },
"required": ["prompt_text"] "required": ["prompt_text"]
+191 -13
View File
@@ -1,6 +1,13 @@
import json import json
import sqlite3
import logging
import urllib.request import urllib.request
import os
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
import jwt
from passlib.context import CryptContext
from fastapi import FastAPI, Depends, HTTPException, status from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@@ -8,45 +15,196 @@ 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
from llm.db_tools import db_get_tasks from llm.db_tools import db_get_tasks, DB_PATH
API_TOKEN = "scud_secret_token_2026" logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.StreamHandler()]
)
JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure"
ALGORITHM = "HS256"
OLLAMA_URL = "http://192.168.11.3:11434/api/chat" OLLAMA_URL = "http://192.168.11.3:11434/api/chat"
MODEL_NAME = "qwen2.5:14b" MODEL_NAME = "qwen2.5:14b"
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
security = HTTPBearer() security = HTTPBearer()
def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)): def get_db():
if credentials.credentials != API_TOKEN: conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def create_access_token(user_id: int, username: str, is_admin: bool) -> str:
payload = {
"sub": str(user_id),
"username": username,
"is_admin": is_admin,
"exp": datetime.utcnow() + timedelta(days=30)
}
return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM)
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]:
try:
token = credentials.credentials
payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM])
user_id = int(payload.get("sub"))
username = payload.get("username")
is_admin = bool(payload.get("is_admin", False))
return {"id": user_id, "username": username, "is_admin": is_admin}
except Exception as e:
logging.warning(f"Auth error: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Неверный токен доступа", detail="Недействительный или просроченный токен авторизации",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
return credentials.credentials
app = FastAPI(title="SCUD Orion AI Context API") app = FastAPI(title="SCUD Orion AI Context API")
app.mount("/static", StaticFiles(directory="static"), name="static") if os.path.exists("static"):
app.mount("/static", StaticFiles(directory="static"), name="static")
class AuthRequest(BaseModel):
username: str
password: str
class CreateUserRequest(BaseModel):
username: str
password: str
full_name: Optional[str] = None
is_admin: Optional[bool] = False
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
class ChatRequest(BaseModel): class ChatRequest(BaseModel):
session_id: str session_id: str
message: str message: str
# === API МАРШРУТЫ ===
@app.get("/") @app.get("/")
def read_root(): def read_root():
return FileResponse("static/index.html") return FileResponse("static/index.html")
@app.post("/api/v1/auth/login")
def login(req: AuthRequest):
username = req.username.strip().lower()
logging.info(f"===> Попытка входа для пользователя: {username}")
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", (username,))
user = cursor.fetchone()
conn.close()
if not user:
logging.warning(f"===> Ошибка: Пользователь {username} не найден")
raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль")
if not pwd_context.verify(req.password, user["password_hash"]):
logging.warning(f"===> Ошибка: Неверный пароль для {username}")
raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль")
is_admin = bool(user["is_admin"]) or (user["username"] == "puh")
token = create_access_token(user["id"], user["username"], is_admin)
logging.info(f"===> УСПЕХ: Авторизован пользователь {username}")
return {"status": "success", "token": token, "username": user["username"], "is_admin": is_admin}
@app.post("/api/v1/auth/change-password")
def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = Depends(get_current_user)):
if not req.new_password or len(req.new_password) < 4:
raise HTTPException(status_code=400, detail="Новый пароль должен содержать минимум 4 символа")
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT password_hash FROM users WHERE id = ?", (current_user["id"],))
user = cursor.fetchone()
if not user or not pwd_context.verify(req.old_password, user["password_hash"]):
conn.close()
raise HTTPException(status_code=400, detail="Неверный старый пароль")
new_hash = pwd_context.hash(req.new_password)
cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, current_user["id"]))
conn.commit()
conn.close()
logging.info(f"Пароль изменен для пользователя ID: {current_user['id']}")
return {"status": "success", "message": "Пароль успешно изменен"}
@app.get("/api/v1/admin/users")
def list_users(current_user: Dict[str, Any] = Depends(get_current_user)):
if not current_user["is_admin"]:
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT id, username, full_name, is_admin, created_at FROM users ORDER BY id ASC")
users = [dict(r) for r in cursor.fetchall()]
conn.close()
return users
@app.post("/api/v1/admin/users")
def create_user(req: CreateUserRequest, current_user: Dict[str, Any] = Depends(get_current_user)):
if not current_user["is_admin"]:
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
username = req.username.strip().lower()
if not username or not req.password:
raise HTTPException(status_code=400, detail="Заполните имя пользователя и пароль")
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT id FROM users WHERE username = ?", (username,))
if cursor.fetchone():
conn.close()
raise HTTPException(status_code=400, detail="Пользователь с таким именем уже существует")
pwd_hash = pwd_context.hash(req.password)
full_name = req.full_name.strip() if req.full_name else None
is_admin = 1 if req.is_admin else 0
cursor.execute(
"INSERT INTO users (username, password_hash, full_name, is_admin) VALUES (?, ?, ?, ?)",
(username, pwd_hash, full_name, is_admin)
)
conn.commit()
conn.close()
logging.info(f"Создан пользователь: {username} (admin={is_admin}) админом {current_user['username']}")
return {"status": "success", "message": f"Пользователь {username} создан"}
@app.delete("/api/v1/admin/users/{user_id}")
def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current_user)):
if not current_user["is_admin"]:
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
if user_id == current_user["id"]:
raise HTTPException(status_code=400, detail="Нельзя удалить самого себя")
conn = get_db()
cursor = conn.cursor()
cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
conn.commit()
conn.close()
logging.info(f"Удален пользователь ID: {user_id}")
return {"status": "success", "message": "Пользователь удален"}
@app.get("/api/v1/tasks") @app.get("/api/v1/tasks")
def get_tasks(token: str = Depends(verify_token)): def get_tasks(user: Dict[str, Any] = Depends(get_current_user)):
return db_get_tasks() return db_get_tasks(user_id=user["id"])
@app.post("/api/v1/chat") @app.post("/api/v1/chat")
def chat_endpoint(req: ChatRequest, token: str = Depends(verify_token)): def chat_endpoint(req: ChatRequest, user: Dict[str, Any] = Depends(get_current_user)):
reply, _ = process_chat_message(req.message) reply, _ = process_chat_message(user_id=user["id"], user_message=req.message)
return {"reply": reply} return {"reply": reply}
# Эндпоинт для гостевого режима (без авторизации и без привязки к проекту)
@app.post("/api/v1/chat/guest") @app.post("/api/v1/chat/guest")
def guest_chat_endpoint(req: ChatRequest): def guest_chat_endpoint(req: ChatRequest):
payload = { payload = {
@@ -58,7 +216,6 @@ def guest_chat_endpoint(req: ChatRequest):
"stream": False, "stream": False,
"options": {"num_predict": 2048, "temperature": 0.3} "options": {"num_predict": 2048, "temperature": 0.3}
} }
try: try:
req_ollama = urllib.request.Request( req_ollama = urllib.request.Request(
OLLAMA_URL, OLLAMA_URL,
@@ -71,3 +228,24 @@ def guest_chat_endpoint(req: ChatRequest):
return {"reply": reply} return {"reply": reply}
except Exception as e: except Exception as e:
return {"reply": f"Ошибка связи с локальной нейросетью: {e}"} return {"reply": f"Ошибка связи с локальной нейросетью: {e}"}
# === СТРОГО В КОНЦЕ: ФОЛЛБЭК СТАТИКИ ===
@app.get("/{file_path:path}")
def serve_static_fallback(file_path: str):
clean_path = file_path.lstrip("/")
target = os.path.join("static", clean_path)
if os.path.isfile(target):
return FileResponse(target)
filename = os.path.basename(clean_path)
target_js = os.path.join("static/js", filename)
if filename.endswith(".js") and os.path.isfile(target_js):
return FileResponse(target_js, media_type="application/javascript")
target_css = os.path.join("static/css", filename)
if filename.endswith(".css") and os.path.isfile(target_css):
return FileResponse(target_css, media_type="text/css")
raise HTTPException(status_code=404, detail="File not found")
+104 -31
View File
@@ -10,7 +10,7 @@
</head> </head>
<body class="bg-slate-100 text-slate-800 h-[100dvh] 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">
<!-- Окно авторизации / Регистрации --> <!-- Окно авторизации -->
<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 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="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="flex items-center space-x-3 mb-6">
@@ -19,36 +19,28 @@
</div> </div>
<div> <div>
<h2 class="text-lg font-bold text-slate-900">SCUD Orion AI</h2> <h2 class="text-lg font-bold text-slate-900">SCUD Orion AI</h2>
<p id="auth-title" class="text-xs text-slate-500">Авторизация в системе</p> <p class="text-xs text-slate-500">Авторизация в системе</p>
</div> </div>
</div> </div>
<form id="auth-form" onsubmit="handleLogin(event)" class="space-y-4"> <div class="space-y-4">
<div> <div>
<label class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1">Имя пользователя</label> <label class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1">Имя пользователя</label>
<input type="text" id="auth-username-input" placeholder="Введите логин..." required <input type="text" id="auth-username-input" placeholder="Введите логин..." required autocomplete="username"
class="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 focus:outline-none focus:border-indigo-600 focus:bg-white transition"> class="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 focus:outline-none focus:border-indigo-600 focus:bg-white transition">
</div> </div>
<div> <div>
<label class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1">Пароль</label> <label class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1">Пароль</label>
<input type="password" id="auth-password-input" placeholder="Введите пароль..." required <input type="password" id="auth-password-input" placeholder="Введите пароль..." required autocomplete="current-password"
class="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 focus:outline-none focus:border-indigo-600 focus:bg-white transition"> class="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 focus:outline-none focus:border-indigo-600 focus:bg-white transition">
</div> </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 id="auth-error" class="hidden text-xs text-red-600 font-medium bg-red-50 p-3 rounded-xl border border-red-200"></div>
Неверный токен доступа.
</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"> <button type="button" onclick="handleLogin()" 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> <i class="fa-solid fa-right-to-bracket"></i>
<span id="auth-submit-text">Войти в систему</span> <span>Войти в систему</span>
</button>
</form>
<div class="mt-3 text-center">
<button type="button" id="auth-toggle-btn" onclick="toggleAuthMode()" class="text-xs text-indigo-600 hover:underline font-medium">
Создать новый аккаунт
</button> </button>
</div> </div>
@@ -57,13 +49,86 @@
<div class="relative flex justify-center text-xs uppercase"><span class="bg-white px-2 text-slate-400 font-medium">Или</span></div> <div class="relative flex justify-center text-xs uppercase"><span class="bg-white px-2 text-slate-400 font-medium">Или</span></div>
</div> </div>
<button onclick="enableGuestMode()" class="w-full bg-slate-100 hover:bg-slate-200 text-slate-700 font-semibold py-2.5 rounded-xl text-xs transition border border-slate-300 flex items-center justify-center gap-2"> <button type="button" onclick="enableGuestMode()" class="w-full bg-slate-100 hover:bg-slate-200 text-slate-700 font-semibold py-2.5 rounded-xl text-xs transition border border-slate-300 flex items-center justify-center gap-2">
<i class="fa-solid fa-user-ninja"></i> <i class="fa-solid fa-user-ninja"></i>
<span>Войти как гость (Локальный ИИ)</span> <span>Войти как гость (Локальный ИИ)</span>
</button> </button>
</div> </div>
</div> </div>
<!-- Модальное окно смены пароля -->
<div id="change-pwd-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
<div class="bg-white rounded-2xl p-6 max-w-sm w-full shadow-2xl border border-slate-200">
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-slate-800 text-sm flex items-center gap-2">
<i class="fa-solid fa-key text-indigo-600"></i> Смена пароля
</h3>
<button type="button" onclick="closeChangePasswordModal()" class="text-slate-400 hover:text-slate-700">
<i class="fa-solid fa-xmark text-lg"></i>
</button>
</div>
<div class="space-y-3">
<div>
<label class="block text-[11px] font-semibold text-slate-600 uppercase mb-1">Старый пароль</label>
<input type="password" id="old-pwd-input" required class="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2 text-xs text-slate-900 focus:outline-none focus:border-indigo-600">
</div>
<div>
<label class="block text-[11px] font-semibold text-slate-600 uppercase mb-1">Новый пароль</label>
<input type="password" id="new-pwd-input" required class="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2 text-xs text-slate-900 focus:outline-none focus:border-indigo-600">
</div>
<div>
<label class="block text-[11px] font-semibold text-slate-600 uppercase mb-1">Повторите новый пароль</label>
<input type="password" id="confirm-pwd-input" required class="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2 text-xs text-slate-900 focus:outline-none focus:border-indigo-600">
</div>
<div id="pwd-error" class="hidden text-xs text-red-600 bg-red-50 p-2 rounded-lg border border-red-200"></div>
<div id="pwd-success" class="hidden text-xs text-emerald-600 bg-emerald-50 p-2 rounded-lg border border-emerald-200"></div>
<button type="button" onclick="handleChangePassword()" class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2.5 rounded-xl text-xs transition shadow-sm mt-2">
Сохранить новый пароль
</button>
</div>
</div>
</div>
<!-- Модальное окно управления пользователями -->
<div id="admin-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
<div class="bg-white rounded-2xl p-6 max-w-lg w-full shadow-2xl border border-slate-200 flex flex-col max-h-[85vh]">
<div class="flex justify-between items-center mb-4 pb-2 border-b border-slate-200">
<h3 class="font-bold text-slate-800 text-sm flex items-center gap-2">
<i class="fa-solid fa-users-gear text-indigo-600"></i> Управление пользователями
</h3>
<button type="button" onclick="closeAdminModal()" class="text-slate-400 hover:text-slate-700">
<i class="fa-solid fa-xmark text-lg"></i>
</button>
</div>
<div class="space-y-2 mb-4 bg-slate-50 p-3.5 rounded-xl border border-slate-200 shrink-0">
<p class="text-[11px] font-bold text-slate-700 uppercase">Создать нового пользователя</p>
<div class="grid grid-cols-2 gap-2">
<input type="text" id="new-user-name" placeholder="Логин *" required class="bg-white border border-slate-300 rounded-lg px-2.5 py-1.5 text-xs">
<input type="password" id="new-user-pwd" placeholder="Пароль *" required class="bg-white border border-slate-300 rounded-lg px-2.5 py-1.5 text-xs">
</div>
<input type="text" id="new-user-fullname" placeholder="ФИО (необязательно)" class="w-full bg-white border border-slate-300 rounded-lg px-2.5 py-1.5 text-xs">
<div class="flex items-center justify-between pt-1">
<label class="flex items-center gap-2 text-xs text-slate-700 cursor-pointer">
<input type="checkbox" id="new-user-is-admin" class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
<span>Права администратора</span>
</label>
<button type="button" onclick="handleCreateUser()" class="bg-indigo-600 hover:bg-indigo-700 text-white font-semibold px-4 py-1.5 rounded-lg text-xs transition">
+ Добавить
</button>
</div>
<div id="admin-msg" class="hidden text-[11px] text-red-600 pt-1"></div>
</div>
<div class="flex-1 overflow-y-auto space-y-2 pr-1" id="admin-users-list">
<div class="text-xs text-slate-400 py-4 text-center">Загрузка пользователей...</div>
</div>
</div>
</div>
<!-- Хедер --> <!-- Хедер -->
<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"> <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.5"> <div class="flex items-center space-x-2.5">
@@ -76,21 +141,30 @@
</div> </div>
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-1.5">
<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 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> </span>
<span id="username-badge" class="hidden text-xs text-indigo-700 font-bold bg-indigo-50 px-2.5 py-1 rounded-full border border-indigo-200"> <span id="username-badge" class="hidden text-xs text-indigo-700 font-bold bg-indigo-50 px-2.5 py-1 rounded-full border border-indigo-200">
Admin puh
</span> </span>
<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"> <button id="admin-users-btn" type="button" onclick="openAdminModal()" class="hidden text-slate-500 hover:text-indigo-600 transition p-2 rounded-xl" title="Управление пользователями">
<i class="fa-solid fa-users-gear text-base"></i>
</button>
<button id="change-pwd-btn" type="button" onclick="openChangePasswordModal()" class="hidden text-slate-500 hover:text-indigo-600 transition p-2 rounded-xl" title="Сменить пароль">
<i class="fa-solid fa-key text-base"></i>
</button>
<button id="tasks-drawer-btn" type="button" 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">
<i class="fa-solid fa-list-check"></i> <i class="fa-solid fa-list-check"></i>
<span>Задачи</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> <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>
<button onclick="logout()" class="text-slate-400 hover:text-red-600 transition p-2 rounded-xl" title="Выйти">
<button type="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> <i class="fa-solid fa-right-from-bracket text-base"></i>
</button> </button>
</div> </div>
@@ -109,19 +183,18 @@
</div> </div>
</div> </div>
<!-- Поле ввода -->
<div class="p-2.5 pb-6 bg-white border-t border-slate-200 shrink-0 z-10 shadow-lg"> <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-center gap-2"> <div class="flex items-center gap-2">
<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"> <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-900 text-sm focus:outline-none resize-none overflow-y-auto max-h-[80px] leading-normal 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 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"> <button type="button" id="send-btn" onclick="sendMessage()" 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>Отправить</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> </div>
</div> </div>
</div> </div>
@@ -134,20 +207,20 @@
<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-3"> <div class="flex items-center gap-3">
<button onclick="loadTasks()" class="text-xs text-slate-500 hover:text-indigo-600 transition p-1" title="Обновить"> <button type="button" onclick="loadTasks()" class="text-xs text-slate-500 hover:text-indigo-600 transition p-1" title="Обновить">
<i class="fa-solid fa-rotate-right text-sm"></i> <i class="fa-solid fa-rotate-right text-sm"></i>
</button> </button>
<button onclick="toggleDrawer()" class="text-slate-500 hover:text-slate-800 transition p-1"> <button type="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-semibold 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 type="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 whitespace-nowrap">В работе</button> <button type="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 whitespace-nowrap">Бэклог</button> <button type="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 whitespace-nowrap">Завершено</button> <button type="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 space-y-3 bg-slate-50/50 pb-8"> <div id="tasks-container" class="flex-1 overflow-y-auto p-3.5 space-y-3 bg-slate-50/50 pb-8">
+38 -20
View File
@@ -1,31 +1,46 @@
const AUTH_TOKEN_KEY = "scud_api_auth_token"; 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 API_TOKEN = localStorage.getItem(AUTH_TOKEN_KEY) || "";
let CURRENT_USERNAME = localStorage.getItem('scud_username') || ""; let CURRENT_USERNAME = localStorage.getItem("scud_username") || "";
let IS_GUEST = localStorage.getItem('scud_is_guest') === 'true'; let IS_ADMIN = localStorage.getItem("scud_is_admin") === "true";
let currentFilter = 'ALL'; let IS_GUEST = localStorage.getItem("scud_is_guest") === "true";
let allTasks = [];
let inputHistory = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); let inputHistory = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
let historyIndex = -1; let historyIndex = -1;
document.addEventListener('DOMContentLoaded', () => { document.addEventListener("DOMContentLoaded", () => {
const userInputEl = document.getElementById('user-input'); const userInputEl = document.getElementById("user-input");
if (userInputEl) { if (userInputEl) {
userInputEl.addEventListener('input', function() { userInputEl.addEventListener("input", function() {
this.style.height = 'auto'; this.style.height = "auto";
this.style.height = Math.min(this.scrollHeight, 80) + 'px'; this.style.height = Math.min(this.scrollHeight, 80) + "px";
}); });
userInputEl.addEventListener('keydown', function(e) { userInputEl.addEventListener("keydown", function(e) {
if (e.key === 'Enter' && !e.shiftKey) { // Отправка по Enter
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
document.getElementById('chat-form').requestSubmit();
const val = this.value.trim();
if (val) {
// Сохраняем команду в историю
if (inputHistory.length === 0 || inputHistory[inputHistory.length - 1] !== val) {
inputHistory.push(val);
if (inputHistory.length > 50) inputHistory.shift();
localStorage.setItem(STORAGE_KEY, JSON.stringify(inputHistory));
}
historyIndex = -1;
}
if (typeof sendMessage === "function") {
sendMessage(e);
}
} }
else if (e.key === 'ArrowUp') { // История: стрелка ВВЕРХ
else if (e.key === "ArrowUp") {
if (inputHistory.length > 0 && historyIndex < inputHistory.length - 1) { if (inputHistory.length > 0 && historyIndex < inputHistory.length - 1) {
e.preventDefault(); e.preventDefault();
if (historyIndex === -1) { if (historyIndex === -1) {
@@ -33,11 +48,12 @@ document.addEventListener('DOMContentLoaded', () => {
} }
historyIndex++; historyIndex++;
this.value = inputHistory[inputHistory.length - 1 - historyIndex]; this.value = inputHistory[inputHistory.length - 1 - historyIndex];
this.dispatchEvent(new Event('input')); this.dispatchEvent(new Event("input"));
setTimeout(() => this.setSelectionRange(this.value.length, this.value.length), 0); setTimeout(() => this.setSelectionRange(this.value.length, this.value.length), 0);
} }
} }
else if (e.key === 'ArrowDown') { // История: стрелка ВНИЗ
else if (e.key === "ArrowDown") {
if (historyIndex !== -1) { if (historyIndex !== -1) {
e.preventDefault(); e.preventDefault();
if (historyIndex > 0) { if (historyIndex > 0) {
@@ -45,9 +61,9 @@ document.addEventListener('DOMContentLoaded', () => {
this.value = inputHistory[inputHistory.length - 1 - historyIndex]; this.value = inputHistory[inputHistory.length - 1 - historyIndex];
} else { } else {
historyIndex = -1; historyIndex = -1;
this.value = this.dataset.draft || ''; this.value = this.dataset.draft || "";
} }
this.dispatchEvent(new Event('input')); this.dispatchEvent(new Event("input"));
setTimeout(() => this.setSelectionRange(this.value.length, this.value.length), 0); setTimeout(() => this.setSelectionRange(this.value.length, this.value.length), 0);
} }
} }
@@ -60,7 +76,9 @@ document.addEventListener('DOMContentLoaded', () => {
} else if (API_TOKEN) { } else if (API_TOKEN) {
hideAuthModal(); hideAuthModal();
updateUIState(); updateUIState();
loadTasks(); if (typeof loadTasks === "function") {
loadTasks();
}
} else { } else {
showAuthModal(); showAuthModal();
} }
+252 -60
View File
@@ -1,79 +1,68 @@
let authMode = 'login'; // 'login' или 'register'
function showAuthModal() { function showAuthModal() {
document.getElementById('auth-modal').classList.remove('hidden'); const el = document.getElementById("auth-modal");
if (el) el.classList.remove("hidden");
} }
function hideAuthModal() { function hideAuthModal() {
document.getElementById('auth-modal').classList.add('hidden'); const el = document.getElementById("auth-modal");
} if (el) el.classList.add("hidden");
function setAuthMode(mode) {
authMode = mode;
const titleEl = document.getElementById('auth-title');
const submitBtnText = document.getElementById('auth-submit-text');
const toggleBtn = document.getElementById('auth-toggle-btn');
const errorEl = document.getElementById('auth-error');
errorEl.classList.add('hidden');
if (mode === 'register') {
titleEl.innerText = "Регистрация нового пользователя";
submitBtnText.innerText = "Зарегистрироваться";
toggleBtn.innerText = "Уже есть аккаунт? Войти";
} else {
titleEl.innerText = "Авторизация в системе";
submitBtnText.innerText = "Войти в систему";
toggleBtn.innerText = "Создать новый аккаунт";
}
}
function toggleAuthMode() {
setAuthMode(authMode === 'login' ? 'register' : 'login');
} }
async function handleLogin(e) { async function handleLogin(e) {
e.preventDefault(); if (e && e.preventDefault) e.preventDefault();
const usernameInput = document.getElementById('auth-username-input');
const passwordInput = document.getElementById('auth-password-input'); const usernameInput = document.getElementById("auth-username-input");
const errorEl = document.getElementById('auth-error'); const passwordInput = document.getElementById("auth-password-input");
const errorEl = document.getElementById("auth-error");
if (!usernameInput || !passwordInput) return;
const username = usernameInput.value.trim(); const username = usernameInput.value.trim();
const password = passwordInput.value; const password = passwordInput.value;
if (!username || !password) return; if (!username || !password) return;
errorEl.classList.add('hidden'); if (errorEl) errorEl.classList.add("hidden");
const endpoint = authMode === 'register' ? '/api/v1/auth/register' : '/api/v1/auth/login';
try { try {
const res = await fetch(endpoint, { const res = await fetch("/api/v1/auth/login", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }) body: JSON.stringify({ username, password })
}); });
const data = await res.json(); const data = await res.json();
if (res.status === 200) { if (res.status === 200) {
// Используем прямой строковый ключ, чтобы избежать ошибки ReferenceError
API_TOKEN = data.token; API_TOKEN = data.token;
CURRENT_USERNAME = data.username; CURRENT_USERNAME = data.username;
IS_ADMIN = data.is_admin;
IS_GUEST = false; IS_GUEST = false;
localStorage.setItem(AUTH_TOKEN_KEY, data.token); localStorage.setItem("scud_api_auth_token", data.token);
localStorage.setItem('scud_username', data.username); localStorage.setItem("scud_username", data.username);
localStorage.removeItem('scud_is_guest'); localStorage.setItem("scud_is_admin", data.is_admin ? "true" : "false");
localStorage.removeItem("scud_is_guest");
hideAuthModal(); hideAuthModal();
updateUIState(); updateUIState();
loadTasks();
if (typeof loadTasks === 'function') {
loadTasks();
}
} else { } else {
errorEl.innerText = data.detail || "Ошибка авторизации"; if (errorEl) {
errorEl.classList.remove('hidden'); errorEl.innerText = data.detail || "Ошибка авторизации";
errorEl.classList.remove("hidden");
}
} }
} catch (err) { } catch (err) {
errorEl.innerText = "Ошибка соединения с сервером"; console.error("[Auth Error]", err);
errorEl.classList.remove('hidden'); if (errorEl) {
errorEl.innerText = "Ошибка соединения с сервером";
errorEl.classList.remove("hidden");
}
} }
} }
@@ -81,36 +70,239 @@ function enableGuestMode() {
IS_GUEST = true; IS_GUEST = true;
API_TOKEN = ""; API_TOKEN = "";
CURRENT_USERNAME = "Гость"; CURRENT_USERNAME = "Гость";
localStorage.setItem('scud_is_guest', 'true'); IS_ADMIN = false;
localStorage.setItem("scud_is_guest", "true");
hideAuthModal(); hideAuthModal();
updateUIState(); updateUIState();
} }
function logout() { function logout() {
localStorage.removeItem(AUTH_TOKEN_KEY); localStorage.removeItem("scud_api_auth_token");
localStorage.removeItem('scud_username'); localStorage.removeItem("scud_username");
localStorage.removeItem('scud_is_guest'); localStorage.removeItem("scud_is_admin");
localStorage.removeItem("scud_is_guest");
API_TOKEN = ""; API_TOKEN = "";
CURRENT_USERNAME = ""; CURRENT_USERNAME = "";
IS_ADMIN = false;
IS_GUEST = false; IS_GUEST = false;
showAuthModal(); showAuthModal();
} }
function updateUIState() { function updateUIState() {
const tasksBtn = document.getElementById('tasks-drawer-btn'); const tasksBtn = document.getElementById("tasks-drawer-btn");
const guestBadge = document.getElementById('guest-badge'); const adminBtn = document.getElementById("admin-users-btn");
const usernameBadge = document.getElementById('username-badge'); const changePwdBtn = document.getElementById("change-pwd-btn");
const guestBadge = document.getElementById("guest-badge");
const usernameBadge = document.getElementById("username-badge");
if (IS_GUEST) { if (typeof IS_GUEST !== 'undefined' && IS_GUEST) {
if (tasksBtn) tasksBtn.classList.add('hidden'); if (tasksBtn) tasksBtn.classList.add("hidden");
if (guestBadge) guestBadge.classList.remove('hidden'); if (adminBtn) adminBtn.classList.add("hidden");
if (usernameBadge) usernameBadge.classList.add('hidden'); if (changePwdBtn) changePwdBtn.classList.add("hidden");
if (guestBadge) guestBadge.classList.remove("hidden");
if (usernameBadge) usernameBadge.classList.add("hidden");
} else { } else {
if (tasksBtn) tasksBtn.classList.remove('hidden'); if (tasksBtn) tasksBtn.classList.remove("hidden");
if (guestBadge) guestBadge.classList.add('hidden'); if (changePwdBtn) changePwdBtn.classList.remove("hidden");
if (guestBadge) guestBadge.classList.add("hidden");
if (usernameBadge) { if (usernameBadge) {
usernameBadge.innerText = CURRENT_USERNAME || 'User'; usernameBadge.innerText = (typeof CURRENT_USERNAME !== 'undefined' && CURRENT_USERNAME) ? CURRENT_USERNAME : "User";
usernameBadge.classList.remove('hidden'); usernameBadge.classList.remove("hidden");
}
if (adminBtn) {
const isAdminUser = (typeof IS_ADMIN !== 'undefined' && IS_ADMIN) || (typeof CURRENT_USERNAME !== 'undefined' && CURRENT_USERNAME === "puh");
if (isAdminUser) {
adminBtn.classList.remove("hidden");
} else {
adminBtn.classList.add("hidden");
}
} }
} }
} }
function openChangePasswordModal() {
const el = document.getElementById("change-pwd-modal");
if (el) el.classList.remove("hidden");
}
function closeChangePasswordModal() {
const el = document.getElementById("change-pwd-modal");
if (el) el.classList.add("hidden");
const err = document.getElementById("pwd-error");
const succ = document.getElementById("pwd-success");
if (err) err.classList.add("hidden");
if (succ) succ.classList.add("hidden");
document.getElementById("old-pwd-input").value = "";
document.getElementById("new-pwd-input").value = "";
const confirmInput = document.getElementById("confirm-pwd-input");
if (confirmInput) confirmInput.value = "";
}
async function handleChangePassword(e) {
if (e && e.preventDefault) e.preventDefault();
const old_password = document.getElementById("old-pwd-input").value;
const new_password = document.getElementById("new-pwd-input").value;
const confirmInput = document.getElementById("confirm-pwd-input");
const confirm_password = confirmInput ? confirmInput.value : new_password;
const errorEl = document.getElementById("pwd-error");
const successEl = document.getElementById("pwd-success");
if (errorEl) errorEl.classList.add("hidden");
if (successEl) successEl.classList.add("hidden");
if (new_password !== confirm_password) {
if (errorEl) {
errorEl.innerText = "Новые пароли не совпадают";
errorEl.classList.remove("hidden");
}
return;
}
try {
const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
const res = await fetch("/api/v1/auth/change-password", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + token
},
body: JSON.stringify({ old_password, new_password })
});
const data = await res.json();
if (res.status === 200) {
if (successEl) {
successEl.innerText = "Пароль успешно изменен!";
successEl.classList.remove("hidden");
}
setTimeout(closeChangePasswordModal, 1500);
} else {
if (errorEl) {
errorEl.innerText = data.detail || "Ошибка при смене пароля";
errorEl.classList.remove("hidden");
}
}
} catch (err) {
if (errorEl) {
errorEl.innerText = "Ошибка соединения с сервером";
errorEl.classList.remove("hidden");
}
}
}
function openAdminModal() {
const el = document.getElementById("admin-modal");
if (el) el.classList.remove("hidden");
loadUsersList();
}
function closeAdminModal() {
const el = document.getElementById("admin-modal");
if (el) el.classList.add("hidden");
}
async function loadUsersList() {
const listEl = document.getElementById("admin-users-list");
if (!listEl) return;
listEl.innerHTML = '<div class="text-xs text-slate-400 py-4 text-center">Загрузка пользователей...</div>';
try {
const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
const res = await fetch("/api/v1/admin/users", {
headers: { "Authorization": "Bearer " + token }
});
const users = await res.json();
if (res.status === 200) {
listEl.innerHTML = users.map(u => {
const adminTag = u.is_admin ? '<span class="ml-1.5 text-[9px] bg-indigo-100 text-indigo-700 px-1.5 py-0.5 rounded font-bold">ADMIN</span>' : '<span class="ml-1.5 text-[9px] bg-slate-100 text-slate-600 px-1.5 py-0.5 rounded">USER</span>';
const fullNameHtml = u.full_name ? `<div class="text-[11px] text-slate-500 font-normal">${u.full_name}</div>` : '';
const dateStr = u.created_at ? u.created_at.split(' ')[0] : '—';
const deleteBtn = u.username !== CURRENT_USERNAME ? `<button type="button" onclick="deleteUser(${u.id}, '${u.username}')" class="text-red-500 hover:text-red-700 p-1"><i class="fa-solid fa-trash-can"></i></button>` : '<span class="text-[10px] text-slate-400">Вы</span>';
return `
<div class="flex justify-between items-center bg-slate-50 border border-slate-200 p-2.5 rounded-xl text-xs">
<div>
<div class="flex items-center">
<span class="font-bold text-slate-800">${u.username}</span>
${adminTag}
</div>
${fullNameHtml}
<div class="text-[10px] text-slate-400 mt-0.5">Создан: ${dateStr}</div>
</div>
${deleteBtn}
</div>
`;
}).join("");
} else {
listEl.innerHTML = `<div class="text-xs text-red-500 py-2">${users.detail}</div>`;
}
} catch (err) {
listEl.innerHTML = '<div class="text-xs text-red-500 py-2">Ошибка загрузки пользователей</div>';
}
}
async function handleCreateUser(e) {
if (e && e.preventDefault) e.preventDefault();
const username = document.getElementById("new-user-name").value.trim();
const password = document.getElementById("new-user-pwd").value;
const fullNameInput = document.getElementById("new-user-fullname");
const full_name = fullNameInput ? fullNameInput.value.trim() : "";
const adminCheckbox = document.getElementById("new-user-is-admin");
const is_admin = adminCheckbox ? adminCheckbox.checked : false;
const msgEl = document.getElementById("admin-msg");
if (msgEl) msgEl.classList.add("hidden");
try {
const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
const res = await fetch("/api/v1/admin/users", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + token
},
body: JSON.stringify({ username, password, full_name, is_admin })
});
const data = await res.json();
if (res.status === 200) {
document.getElementById("new-user-name").value = "";
document.getElementById("new-user-pwd").value = "";
if (fullNameInput) fullNameInput.value = "";
if (adminCheckbox) adminCheckbox.checked = false;
loadUsersList();
} else {
if (msgEl) {
msgEl.innerText = data.detail || "Ошибка";
msgEl.classList.remove("hidden");
}
}
} catch (err) {
if (msgEl) {
msgEl.innerText = "Ошибка связи с сервером";
msgEl.classList.remove("hidden");
}
}
}
async function deleteUser(userId, username) {
if (!confirm("Удалить пользователя " + username + "?")) return;
try {
const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
await fetch("/api/v1/admin/users/" + userId, {
method: "DELETE",
headers: { "Authorization": "Bearer " + token }
});
loadUsersList();
} catch (err) {
alert("Ошибка при удалении");
}
}
+62 -36
View File
@@ -1,71 +1,97 @@
async function sendMessage(e) { async function sendMessage(e) {
e.preventDefault(); if (e && e.preventDefault) e.preventDefault();
const input = document.getElementById('user-input');
const chatWindow = document.getElementById('chat-window'); const input = document.getElementById("user-input");
const sendBtn = document.getElementById('send-btn'); const chatWindow = document.getElementById("chat-window");
const sendBtn = document.getElementById("send-btn");
if (!input || !chatWindow) return;
const text = input.value.trim(); const text = input.value.trim();
if (!text) return; if (!text) return;
if (inputHistory[inputHistory.length - 1] !== text) { // Вывод сообщения пользователя
inputHistory.push(text); const userMsgHtml = `
if (inputHistory.length > 50) inputHistory.shift(); <div class="flex justify-end mb-3">
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"> <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} ${escapeHtml(text)}
</div> </div>
</div> </div>
`; `;
input.value = ''; chatWindow.insertAdjacentHTML("beforeend", userMsgHtml);
input.style.height = 'auto';
input.value = "";
input.style.height = "auto";
chatWindow.scrollTop = chatWindow.scrollHeight; chatWindow.scrollTop = chatWindow.scrollHeight;
sendBtn.disabled = true; if (sendBtn) {
sendBtn.classList.add('opacity-50'); sendBtn.disabled = true;
sendBtn.classList.add("opacity-50");
}
const endpoint = IS_GUEST ? '/api/v1/chat/guest' : '/api/v1/chat'; const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
const headers = { 'Content-Type': 'application/json' }; const isGuest = typeof IS_GUEST !== 'undefined' ? IS_GUEST : (localStorage.getItem("scud_is_guest") === "true");
if (!IS_GUEST) {
headers['Authorization'] = `Bearer ${API_TOKEN}`; const endpoint = isGuest ? "/api/v1/chat/guest" : "/api/v1/chat";
const headers = { "Content-Type": "application/json" };
if (!isGuest && token) {
headers["Authorization"] = "Bearer " + token;
} }
try { try {
const res = await fetch(endpoint, { const res = await fetch(endpoint, {
method: 'POST', method: "POST",
headers: headers, headers: headers,
body: JSON.stringify({ session_id: SESSION_ID, message: text }) body: JSON.stringify({ session_id: "web_session_main", message: text })
}); });
if (res.status === 401 && !IS_GUEST) { if (res.status === 401 && !isGuest) {
logout(); if (typeof logout === 'function') logout();
return; return;
} }
const data = await res.json(); const data = await res.json();
const assistantTitle = IS_GUEST ? "Локальная нейросеть (Гость)" : "ИИ-Ассистент SCUD Orion AI"; const assistantTitle = isGuest ? "Локальная нейросеть (Гость)" : "ИИ-Ассистент SCUD Orion AI";
const replyText = data.reply || "Пустой ответ от нейросети";
chatWindow.innerHTML += ` const botMsgHtml = `
<div class="bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm max-w-2xl"> <div class="bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm max-w-2xl mb-3">
<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-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
<p class="text-slate-800 text-xs sm:text-sm whitespace-pre-wrap leading-relaxed">${data.reply}</p> <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">${escapeHtml(replyText)}</p>
</div> </div>
`; `;
chatWindow.insertAdjacentHTML("beforeend", botMsgHtml);
chatWindow.scrollTop = chatWindow.scrollHeight; chatWindow.scrollTop = chatWindow.scrollHeight;
if (!IS_GUEST) loadTasks();
if (!isGuest && typeof loadTasks === 'function') {
loadTasks();
}
} catch (err) { } catch (err) {
chatWindow.innerHTML += ` console.error("[Chat Error]", err);
<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"> const errorHtml = `
<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 mb-3">
Ошибка связи с сервером. Ошибка связи с сервером.
</div> </div>
`; `;
chatWindow.insertAdjacentHTML("beforeend", errorHtml);
chatWindow.scrollTop = chatWindow.scrollHeight;
} finally { } finally {
sendBtn.disabled = false; if (sendBtn) {
sendBtn.classList.remove('opacity-50'); sendBtn.disabled = false;
sendBtn.classList.remove("opacity-50");
}
} }
} }
function escapeHtml(text) {
if (!text) return "";
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
+87 -35
View File
@@ -1,20 +1,26 @@
let currentFilter = 'ALL';
let allTasks = [];
function toggleDrawer() { function toggleDrawer() {
if (IS_GUEST) return; if (typeof IS_GUEST !== 'undefined' && IS_GUEST) return;
const drawer = document.getElementById('task-drawer'); const drawer = document.getElementById("task-drawer");
const backdrop = document.getElementById('drawer-backdrop'); const backdrop = document.getElementById("drawer-backdrop");
const isHidden = drawer.classList.contains('translate-x-full'); if (!drawer) return;
const isHidden = drawer.classList.contains("translate-x-full");
if (isHidden) { if (isHidden) {
drawer.classList.remove('translate-x-full'); drawer.classList.remove("translate-x-full");
backdrop.classList.remove('hidden'); if (backdrop) backdrop.classList.remove("hidden");
loadTasks();
} else { } else {
drawer.classList.add('translate-x-full'); drawer.classList.add("translate-x-full");
backdrop.classList.add('hidden'); if (backdrop) backdrop.classList.add("hidden");
} }
} }
function setFilter(status) { function setFilter(status) {
currentFilter = status; currentFilter = status;
['ALL', 'IN_PROGRESS', 'BACKLOG', 'COMPLETED'].forEach(f => { ["ALL", "IN_PROGRESS", "BACKLOG", "COMPLETED"].forEach(f => {
const btn = document.getElementById(`filter-${f}`); const btn = document.getElementById(`filter-${f}`);
if (btn) { if (btn) {
btn.className = (f === status) btn.className = (f === status)
@@ -26,26 +32,71 @@ function setFilter(status) {
} }
async function loadTasks() { async function loadTasks() {
if (IS_GUEST || !API_TOKEN) return; const badge = document.getElementById("task-count-badge");
const container = document.getElementById("tasks-container");
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
const isGuest = typeof IS_GUEST !== 'undefined' ? IS_GUEST : (localStorage.getItem("scud_is_guest") === "true");
if (isGuest || !token) {
if (badge) badge.innerText = "0";
return;
}
try { try {
const res = await fetch('/api/v1/tasks', { const res = await fetch("/api/v1/tasks", {
headers: { 'Authorization': `Bearer ${API_TOKEN}` } headers: {
"Authorization": "Bearer " + token,
"Content-Type": "application/json"
}
}); });
if (res.status === 401) { if (res.status === 401) {
logout(); if (typeof logout === 'function') logout();
return; return;
} }
allTasks = await res.json();
document.getElementById('task-count-badge').innerText = allTasks.length; if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
const data = await res.json();
// Гибкое определение структуры данных (массив или объект с ключом tasks)
if (Array.isArray(data)) {
allTasks = data;
} else if (data && Array.isArray(data.tasks)) {
allTasks = data.tasks;
} else if (data && typeof data === 'object') {
allTasks = Object.values(data).find(val => Array.isArray(val)) || [];
} else {
allTasks = [];
}
if (badge) {
badge.innerText = allTasks.length.toString();
}
renderTasks(); renderTasks();
} catch (err) { } catch (err) {
document.getElementById('tasks-container').innerHTML = `<div class="text-red-500 text-xs py-4 text-center">Ошибка загрузки задач</div>`; console.error("[Tasks Error]", err);
if (badge) badge.innerText = "0";
if (container) {
container.innerHTML = `<div class="text-red-500 text-xs py-8 text-center font-medium">Ошибка обработки списка задач</div>`;
}
} }
} }
function renderTasks() { function renderTasks() {
const container = document.getElementById('tasks-container'); const container = document.getElementById("tasks-container");
const filtered = allTasks.filter(t => currentFilter === 'ALL' || t.status === currentFilter); if (!container) return;
if (!Array.isArray(allTasks)) {
allTasks = [];
}
const filtered = allTasks.filter(t => currentFilter === "ALL" || t.status === currentFilter);
if (filtered.length === 0) { if (filtered.length === 0) {
container.innerHTML = `<div class="text-slate-400 text-xs py-8 text-center">Нет задач с выбранным фильтром</div>`; container.innerHTML = `<div class="text-slate-400 text-xs py-8 text-center">Нет задач с выбранным фильтром</div>`;
@@ -53,41 +104,42 @@ function renderTasks() {
} }
container.innerHTML = filtered.map(t => { container.innerHTML = filtered.map(t => {
let statusBadge = 'bg-slate-100 text-slate-600 border-slate-200'; let statusBadge = "bg-slate-100 text-slate-600 border-slate-200";
let cardBg = 'bg-white'; let cardBg = "bg-white";
if (t.status === 'COMPLETED') {
statusBadge = 'bg-emerald-50 text-emerald-700 border-emerald-300 font-semibold'; if (t.status === "COMPLETED") {
cardBg = 'bg-emerald-50/20'; statusBadge = "bg-emerald-50 text-emerald-700 border-emerald-300 font-semibold";
} else if (t.status === 'IN_PROGRESS') { cardBg = "bg-emerald-50/20";
statusBadge = 'bg-amber-50 text-amber-700 border-amber-300 font-bold'; } else if (t.status === "IN_PROGRESS") {
cardBg = 'bg-amber-50/20 border-amber-200'; 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'; 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'; if (t.priority === "HIGH") priorityBadge = "text-red-700 bg-red-50 border-red-200 font-bold";
let dueDateHtml = t.due_date ? ` 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"> <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> <i class="fa-solid fa-clock text-amber-600"></i>
<span>Срок: ${t.due_date}</span> <span>Срок: ${t.due_date}</span>
</div>` : ''; </div>` : "";
return ` return `
<div class="${cardBg} border border-slate-200 rounded-xl p-3.5 shadow-sm hover:shadow-md transition"> <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 justify-between items-center mb-1.5">
<div class="flex items-center gap-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="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 || t.id || 'TASK'}</span>
<span class="text-[10px] uppercase px-1.5 py-0.5 rounded border ${priorityBadge}">${t.priority || 'HIGH'}</span> <span class="text-[10px] uppercase px-1.5 py-0.5 rounded border ${priorityBadge}">${t.priority || 'MEDIUM'}</span>
</div> </div>
<span class="text-[10px] uppercase px-2 py-0.5 rounded border ${statusBadge}">${t.status}</span> <span class="text-[10px] uppercase px-2 py-0.5 rounded border ${statusBadge}">${t.status || 'BACKLOG'}</span>
</div> </div>
<h3 class="text-xs font-semibold text-slate-800 mb-1 leading-snug">${t.title}</h3> <h3 class="text-xs font-semibold text-slate-800 mb-1 leading-snug">${t.title || t.description || ''}</h3>
<div class="text-[10px] text-slate-400 font-mono flex items-center gap-1"> <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> <i class="fa-solid fa-folder-closed text-slate-300"></i>
<span>${t.module}</span> <span>${t.module || 'General'}</span>
</div> </div>
${dueDateHtml} ${dueDateHtml}
</div> </div>
`; `;
}).join(''); }).join("");
} }