feat(etl): stable pipeline, exception registry in SQLite, multi-pass aggregation and db_cli
This commit is contained in:
+336
-236
@@ -3,18 +3,22 @@
|
||||
FILE: modules/web_api/llm/agent.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: web_api / llm (Core Agent Coordinator)
|
||||
ROLE: Оркестратор диалога, диспетчер реляционных узлов промпта,
|
||||
управление сессионными стейтами, Topic Drift Guard и очистка контекста.
|
||||
ROLE: Нативный оркестратор диалога, диспетчер инструментов (Function Calling)
|
||||
и управление сессионными стейтами через ContextManager.
|
||||
|
||||
AI-CONTEXT-ANCHORS:
|
||||
- ANCHOR[AGENT_IMPORTS]: Системные и доменные импорты.
|
||||
- ANCHOR[AGENT_MAIN_PIPELINE]: Основная точка входа process_chat_message.
|
||||
- ANCHOR[AGENT_SYSTEM_PROMPT]: Формирование динамического контекста.
|
||||
- ANCHOR[AGENT_TOOL_DISPATCHER]: Исполнение нативных вызовов инструментов.
|
||||
- ANCHOR[AGENT_TOPIC_DRIFT]: Защита контекста и обработка свободных тем.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
# =============================================================================
|
||||
# БЛОК 1: ИМПОРТЫ И ИНИЦИАЛИЗАЦИЯ СИСТЕМНЫХ МОДУЛЕЙ
|
||||
# =============================================================================
|
||||
# ANCHOR[AGENT_IMPORTS]
|
||||
import sys
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
|
||||
from .db.connection import get_db_connection
|
||||
@@ -26,6 +30,8 @@ from .db_tools import (
|
||||
db_update_task_status,
|
||||
db_delete_task,
|
||||
db_add_task,
|
||||
db_tasks_edit,
|
||||
db_export_tasks_markdown,
|
||||
db_get_rules,
|
||||
db_set_session_state,
|
||||
db_get_session_state,
|
||||
@@ -46,6 +52,12 @@ from .core.calendar_utils import get_dynamic_calendar_context
|
||||
from .core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed
|
||||
from .core.ollama_client import call_ollama_chat
|
||||
from .core.fast_path import handle_fast_path_intercept
|
||||
from .core.context_manager import (
|
||||
save_tool_interaction,
|
||||
save_dialog_interaction,
|
||||
mark_last_user_message_ephemeral,
|
||||
close_tool_session_and_cleanup
|
||||
)
|
||||
|
||||
logger = logging.getLogger("SCUD_AGENT")
|
||||
logger.setLevel(logging.INFO)
|
||||
@@ -58,9 +70,7 @@ if not logger.handlers:
|
||||
logger.addHandler(handler)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# БЛОК 2: ГЛАВНЫЙ КОНВЕЙЕР ОБРАБОТКИ СООБЩЕНИЙ ЧАТА
|
||||
# =============================================================================
|
||||
# ANCHOR[AGENT_MAIN_PIPELINE]
|
||||
def process_chat_message(
|
||||
user_id: int,
|
||||
user_message: str,
|
||||
@@ -70,40 +80,29 @@ def process_chat_message(
|
||||
session_id: str = "web_session_main"
|
||||
) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||
"""
|
||||
Главная функция обработки сообщения:
|
||||
1. Очищает осиротевшие эфемерные сообщения.
|
||||
2. Перехватывает быстрые кнопки (Fast-Path).
|
||||
3. Собирает контекст и передает управление Ollama.
|
||||
4. Выполняет Tool Calls и управляет счетчиком Topic Drift.
|
||||
Главный конвейер обработки входящего сообщения чата на базе нативного Function Calling.
|
||||
"""
|
||||
logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}")
|
||||
|
||||
# 🧹 ГАРБАДЖ-КОЛЛЕКТОР: Если стейт сессии пуст, удаляем висящие эфемерные сообщения
|
||||
session_state = db_get_session_state(session_id)
|
||||
if not session_state:
|
||||
db_purge_ephemeral_messages(session_id)
|
||||
|
||||
full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message
|
||||
|
||||
# Перехват нажатия кнопок («Подтвердить», «Отменить», «Завершить») без вызова LLM
|
||||
# 1. Быстрый перехват строго системных кнопок UI (подтверждение превью промпта)
|
||||
fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state)
|
||||
if fast_path_res:
|
||||
return fast_path_res
|
||||
|
||||
# Сохранение входящего сообщения оператора
|
||||
is_user_ephemeral = 1 if session_state else 0
|
||||
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=is_user_ephemeral)
|
||||
# 2. Фиксация сообщения пользователя
|
||||
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
|
||||
db_history = db_get_chat_history(session_id, limit=20)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# БЛОК 3: ДИНАМИЧЕСКАЯ СБОРКА СИСТЕМНОГО ИНСТРУКТАЖА
|
||||
# =========================================================================
|
||||
dynamic_prompt_text = db_get_active_system_prompt()
|
||||
# ANCHOR[AGENT_SYSTEM_PROMPT]
|
||||
calendar_context = get_dynamic_calendar_context()
|
||||
user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость"
|
||||
|
||||
# Извлечение текущего состояния и счетчика отвлечений (idle_turns)
|
||||
current_state_type = session_state.get("state_type") if session_state else None
|
||||
state_data = session_state.get("data_json") or {} if session_state else {}
|
||||
if not isinstance(state_data, dict):
|
||||
@@ -113,47 +112,61 @@ def process_chat_message(
|
||||
active_state_context = ""
|
||||
if current_state_type == "PROMPT_PREVIEW":
|
||||
active_state_context = (
|
||||
"\n[ВНИМАНИЕ: АКТИВНЫЙ РЕЖИМ ПРЕДПРОСМОТРА ПРОМПТА]\n"
|
||||
"- Сейчас открыт предпросмотр изменения системного промпта.\n"
|
||||
"- Если пользователь просит изменить, скорректировать формулировку или удалить пункт — вызови инструмент db_prompt_node_edit.\n"
|
||||
"- Если пользователь переключился на другую тему — ответь на его вопрос кратко и по делу.\n"
|
||||
"\n[ТЕКУЩИЙ РЕЖИМ: ПРЕДПРОСМОТР СИСТЕМНОГО ПРОМПТА]\n"
|
||||
"- Открыт предпросмотр изменений промпта. Для любых правок вызывай db_prompt_node_edit.\n"
|
||||
)
|
||||
elif current_state_type == "PROMPT_FOLLOWUP":
|
||||
active_state_context = (
|
||||
"\n[ВНИМАНИЕ: АКТИВНА СЕССИЯ РЕДАКТИРОВАНИЯ СИСТЕМНОГО ПРОМПТА]\n"
|
||||
"- Оператор только что применил предыдущее изменение или запросил просмотр промпта.\n"
|
||||
"- Любые команды вида 'добавь X.Y', 'удали X.Y', 'измени X.Y' ОЗНАЧАЮТ ПРОДОЛЖЕНИЕ РАБОТЫ С СИСТЕМНЫМ ПРОМПТОМ -> ВЫЗЫВАЙ db_prompt_node_edit.\n"
|
||||
"- Если запрос оператора не ясен или относится к другой теме — ответь на него естественно.\n"
|
||||
"\n[ТЕКУЩИЙ РЕЖИМ: СЕССИЯ РЕДАКТИРОВАНИЯ ПРОМПТА]\n"
|
||||
"- Оператор просматривает или редактирует системный промпт.\n"
|
||||
"- На любые команды вида 'удали пункт X.Y' или 'удали X.Y' ТЫ ОБЯЗАН ВЫЗВАТЬ db_prompt_node_edit с action='DELETE', section_id=X, item_id=Y.\n"
|
||||
"- На любые команды 'добавь пункт X.Y ...' вызывай action='ADD'.\n"
|
||||
"- Запрещено путать ADD и DELETE.\n"
|
||||
)
|
||||
elif current_state_type == "SNAPSHOTS_VIEW":
|
||||
active_date = state_data.get("query_date", "выбранную дату")
|
||||
active_state_context = (
|
||||
f"\n[ТЕКУЩИЙ РЕЖИМ: ПРОСМОТР СНАПШОТОВ СКУД]\n"
|
||||
f"- Сейчас на экране отображаются снапшоты за {active_date}.\n"
|
||||
f"- Ты можешь форматировать, фильтровать или анализировать этот текущий срез.\n"
|
||||
f"- Если оператор запрашивает ДРУГУЮ дату или день недели, отличную от {active_date} (например: 'за вчера', 'а за 13.08', 'покажи за сегодня') — "
|
||||
f"ТЫ ОБЯЗАН СРАЗУ ВЫЗВАТЬ db_get_snapshots(date_str=...).\n"
|
||||
f"- Запрещено генерировать текст за другую дату по памяти.\n"
|
||||
)
|
||||
elif current_state_type == "SNAPSHOT_DELETE_CONFIRM":
|
||||
active_state_context = (
|
||||
"\n[ВНИМАНИЕ: ОЖИДАЕТСЯ ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ СНАПШОТОВ]\n"
|
||||
)
|
||||
elif current_state_type == "TASK_DELETE_CONFIRM":
|
||||
active_state_context = (
|
||||
"\n[ВНИМАНИЕ: ОЖИДАЕТСЯ ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ ЗАДАЧИ]\n"
|
||||
)
|
||||
|
||||
system_prompt_content = (
|
||||
f"Ты — интеллектуальный ассистент SCUD Orion AI. "
|
||||
f"Ты управляешь задачами, аналитикой СКУД и системными настройками с помощью инструментов (tools).\n\n"
|
||||
f"СТРОГИЕ ПРАВИЛА СТИЛЯ:\n"
|
||||
f"- Запрещено использовать панибратские или шутливые обращения. Отвечай профессионально и строго по существу.\n\n"
|
||||
f"[ОКРУЖЕНИЕ]\n"
|
||||
f"Ты — интеллектуальный ассистент SCUD Orion AI.\n"
|
||||
f"Ты управляешь задачами, аналитикой СКУД и системными настройками исключительно через инструменты (tools).\n\n"
|
||||
f"СТРОГИЕ ПРАВИЛА:\n"
|
||||
f"1. К оператору всегда обращайся на Вы.\n"
|
||||
f"2. Для получения данных всегда вызывай соответствующий инструмент:\n"
|
||||
f" - Снапшоты и срезы логов СКУД за любые даты и дни недели -> db_get_snapshots\n"
|
||||
f" - Удаление снапшотов -> db_delete_snapshots\n"
|
||||
f" - Задачи и бэклог (просмотр, создание, смена статуса, удаление, экспорт) -> db_get_tasks, db_tasks_edit\n"
|
||||
f" - Системный промпт -> db_get_system_prompt, db_prompt_node_edit\n"
|
||||
f" - Аномалии СКУД/1С -> db_get_anomalies\n"
|
||||
f" - База знаний -> db_get_rules\n"
|
||||
f" - Статистика БД -> db_get_stats\n"
|
||||
f" - Справка -> db_get_reference\n"
|
||||
f"3. Запрещено сочинять данные от себя без вызова инструментов.\n\n"
|
||||
f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n"
|
||||
f"- Пользователь: {user_info}\n"
|
||||
f"- {calendar_context}\n"
|
||||
f"{active_state_context}\n"
|
||||
f"[ПРАВИЛА ИСПОЛЬЗОВАНИЯ ИНСТРУМЕНТОВ]\n"
|
||||
f"1. Для ЛЮБЫХ операций с системным промптом ТЫ ОБЯЗАН СРАЗУ ВЫЗВАТЬ db_prompt_node_edit:\n"
|
||||
f" - 'удали 2.9' / 'удалить пункт 2.9' -> ВЫЗОВ db_prompt_node_edit(action='DELETE', section_id=2, item_id=9, content='')\n"
|
||||
f" - 'добавь 3.4 Текст' / 'добавить пункт 3.4' -> ВЫЗОВ db_prompt_node_edit(action='ADD', section_id=3, item_id=4, content='Текст')\n"
|
||||
f" - 'измени 1.2 Текст' -> ВЫЗОВ db_prompt_node_edit(action='UPDATE', section_id=1, item_id=2, content='Текст')\n"
|
||||
f" КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО отвечать текстом вроде 'Удален пункт... Напишите подтверждаю'. Только Function Call!\n"
|
||||
f"2. Для просмотра системного промпта ВСЕГДА вызывай db_get_system_prompt().\n"
|
||||
f"3. Для просмотра задач ВСЕГДА вызывай db_get_tasks().\n\n"
|
||||
f"[ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ]:\n{dynamic_prompt_text}"
|
||||
f"{active_state_context}"
|
||||
)
|
||||
|
||||
user_msg_object = {"role": "user", "content": full_user_content}
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# БЛОК 4: ВЫЗОВ НЕЙРОСЕТИ И МАРШРУТИЗАЦИЯ FUNCTION CALLING
|
||||
# =========================================================================
|
||||
# ANCHOR[AGENT_TOOL_DISPATCHER]
|
||||
try:
|
||||
# Режим Vision (OCR)
|
||||
if image_b64:
|
||||
user_msg_object["images"] = [image_b64]
|
||||
messages = [
|
||||
@@ -162,7 +175,6 @@ def process_chat_message(
|
||||
]
|
||||
msg = call_ollama_chat(messages, is_vision=True)
|
||||
else:
|
||||
# Текстовый диалог
|
||||
clean_db_history = [dict(m) for m in db_history]
|
||||
for m in clean_db_history:
|
||||
m.pop("images", None)
|
||||
@@ -171,241 +183,329 @@ def process_chat_message(
|
||||
|
||||
raw_text_reply = msg.get("content", "")
|
||||
tool_calls = msg.get("tool_calls", [])
|
||||
tool_calls = inject_tools_if_needed(user_message, raw_text_reply, tool_calls)
|
||||
|
||||
# Исполнение вызванных моделью инструментов
|
||||
if not tool_calls:
|
||||
tool_calls = inject_tools_if_needed(user_message, raw_text_reply, tool_calls)
|
||||
|
||||
if tool_calls:
|
||||
logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})")
|
||||
messages.append(msg)
|
||||
tool_names_called = []
|
||||
|
||||
tool = tool_calls[0]
|
||||
fn_name = tool["function"]["name"]
|
||||
fn_args = tool["function"].get("arguments", {})
|
||||
if isinstance(fn_args, str):
|
||||
try:
|
||||
fn_args = json.loads(fn_args)
|
||||
except Exception:
|
||||
fn_args = {}
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# TOPIC DRIFT GUARD ДЛЯ ИНСТРУМЕНТОВ:
|
||||
# Если вызваны сторонние инструменты (снапшоты, задачи, статы)
|
||||
# — мгновенно закрываем сессию и вычищаем эфемерный контекст
|
||||
# -----------------------------------------------------------------
|
||||
is_prompt_tool = any(t["function"]["name"] in ["db_prompt_node_edit", "db_get_system_prompt", "db_get_system_prompts"] for t in tool_calls)
|
||||
if not is_prompt_tool and session_state:
|
||||
logger.info(f"Смена темы на инструмент {tool_calls[0]['function']['name']}. Закрываем сессию и очищаем эфемерный контекст.")
|
||||
db_clear_session_state(session_id)
|
||||
db_purge_ephemeral_messages(session_id)
|
||||
session_state = None
|
||||
logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}")
|
||||
|
||||
for tool in tool_calls:
|
||||
fn_name = tool["function"]["name"]
|
||||
fn_args = tool["function"].get("arguments", {})
|
||||
tool_names_called.append(fn_name)
|
||||
logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}")
|
||||
tool_result_content = ""
|
||||
# Ротация контекста: закрываем старую сессию инструмента
|
||||
close_tool_session_and_cleanup(session_id, close_reason=f"ACTIVATE_{fn_name}")
|
||||
session_state = None
|
||||
mark_last_user_message_ephemeral(session_id)
|
||||
|
||||
# --- 4.1. Модуль задач ---
|
||||
if fn_name == "db_get_tasks":
|
||||
# 1. Задачи (Просмотр)
|
||||
if fn_name == "db_get_tasks":
|
||||
raw_tasks = db_get_tasks(user_id, status=fn_args.get("status"))
|
||||
reply_text = "Вот интерактивный список ваших текущих задач:"
|
||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||||
return reply_text, db_get_chat_history(session_id), {
|
||||
"type": "TASK_INTERACTIVE_CARD",
|
||||
"tasks": raw_tasks
|
||||
}
|
||||
|
||||
# 2. Единый диспетчер задач
|
||||
elif fn_name in ["db_tasks_edit", "db_add_task", "db_update_task_status", "db_delete_task"]:
|
||||
action = fn_args.get("action", "UPDATE").upper()
|
||||
if fn_name == "db_add_task": action = "ADD"
|
||||
elif fn_name == "db_delete_task": action = "DELETE"
|
||||
elif fn_name == "db_update_task_status": action = "UPDATE"
|
||||
|
||||
if action == "DELETE":
|
||||
task_id_raw = str(fn_args.get("task_id", "")).replace("#", "").replace("TASK-", "").strip()
|
||||
db_set_session_state(session_id, "TASK_DELETE_CONFIRM", {"task_id": task_id_raw, "idle_turns": 0})
|
||||
reply_text = f"Вы действительно хотите удалить задачу #{task_id_raw}?"
|
||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||||
return reply_text, db_get_chat_history(session_id), {
|
||||
"type": "TASK_DELETE_CONFIRM",
|
||||
"buttons": [
|
||||
{"label": f"Удалить #{task_id_raw}", "value": f"подтверждаю удаление задачи {task_id_raw}", "style": "danger"},
|
||||
{"label": "Отмена", "value": "отмена", "style": "secondary"}
|
||||
]
|
||||
}
|
||||
|
||||
elif action == "EXPORT":
|
||||
export_res = db_export_tasks_markdown(
|
||||
user_id=user_id,
|
||||
filename=fn_args.get("filename"),
|
||||
status_filter=fn_args.get("status")
|
||||
)
|
||||
reply_text = export_res.get("message", "Отчет по задачам успешно экспортирован.")
|
||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0)
|
||||
|
||||
action_payload = None
|
||||
if export_res.get("status") == "success":
|
||||
action_payload = {
|
||||
"type": "FILE_DOWNLOAD_CARD",
|
||||
"filename": export_res.get("filename"),
|
||||
"download_url": export_res.get("download_url"),
|
||||
"tasks_count": export_res.get("tasks_count")
|
||||
}
|
||||
return reply_text, db_get_chat_history(session_id), action_payload
|
||||
|
||||
else:
|
||||
res = db_tasks_edit(
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
task_id=fn_args.get("task_id"),
|
||||
title=fn_args.get("title"),
|
||||
priority=fn_args.get("priority", "MEDIUM"),
|
||||
status=fn_args.get("status"),
|
||||
module=fn_args.get("module", "general"),
|
||||
due_date=fn_args.get("due_date")
|
||||
)
|
||||
raw_tasks = db_get_tasks(user_id)
|
||||
reply_text = "Вот интерактивный список ваших текущих задач:"
|
||||
reply_text = res.get("message", "Операция над задачами выполнена.")
|
||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||||
return reply_text, db_get_chat_history(session_id), {
|
||||
"type": "TASK_INTERACTIVE_CARD",
|
||||
"tasks": raw_tasks
|
||||
}
|
||||
|
||||
# --- 4.2. Прямой просмотр системного промпта из SQLite ---
|
||||
elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]:
|
||||
active_prompt = db_get_active_system_prompt()
|
||||
reply_text = f"Актуальный системный промпт:\n\n{active_prompt}"
|
||||
|
||||
# Переводим сессию в PROMPT_FOLLOWUP для отслеживания Topic Drift
|
||||
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0})
|
||||
# 3. Системный промпт
|
||||
elif fn_name == "db_get_system_prompt":
|
||||
active_prompt = db_get_active_system_prompt()
|
||||
reply_text = f"Актуальный системный промпт:\n\n{active_prompt}"
|
||||
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0})
|
||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||||
return reply_text, db_get_chat_history(session_id), None
|
||||
|
||||
elif fn_name == "db_prompt_node_edit":
|
||||
action = str(fn_args.get("action", "ADD")).upper()
|
||||
|
||||
try:
|
||||
sec_id = int(str(fn_args.get("section_id", 3)).strip())
|
||||
except Exception:
|
||||
sec_id = 3
|
||||
|
||||
try:
|
||||
itm_id = int(str(fn_args.get("item_id", 1)).strip())
|
||||
except Exception:
|
||||
itm_id = 1
|
||||
|
||||
content = str(fn_args.get("content", "")).strip()
|
||||
baseline_prompt = db_get_active_system_prompt()
|
||||
|
||||
with get_db_connection() as temp_conn:
|
||||
temp_cursor = temp_conn.cursor()
|
||||
temp_cursor.execute("""
|
||||
SELECT section_id, item_id, content
|
||||
FROM system_prompt_nodes
|
||||
WHERE prompt_name = 'main_agent' AND is_active = 1
|
||||
ORDER BY section_id, item_id
|
||||
""")
|
||||
existing_nodes = temp_cursor.fetchall()
|
||||
|
||||
nodes_dict = {(sec, itm): txt for sec, itm, txt in existing_nodes}
|
||||
nodes_dict_for_draft = {k: v for k, v in nodes_dict.items() if k != (sec_id, itm_id)} if action == "DELETE" else dict(nodes_dict)
|
||||
if action != "DELETE":
|
||||
nodes_dict_for_draft[(sec_id, itm_id)] = content
|
||||
|
||||
draft_lines = []
|
||||
curr_sec = None
|
||||
for (s_id, i_id), txt in sorted(nodes_dict_for_draft.items()):
|
||||
if i_id == 0:
|
||||
if curr_sec is not None: draft_lines.append("")
|
||||
draft_lines.append(f"{s_id}. {txt}")
|
||||
curr_sec = s_id
|
||||
else:
|
||||
draft_lines.append(f" {s_id}.{i_id}. {txt}")
|
||||
merged_prompt = "\n".join(draft_lines)
|
||||
|
||||
diff_lines = []
|
||||
curr_sec = None
|
||||
display_nodes = dict(nodes_dict)
|
||||
if action != "DELETE":
|
||||
display_nodes[(sec_id, itm_id)] = content
|
||||
|
||||
for (s_id, i_id), txt in sorted(display_nodes.items()):
|
||||
if i_id == 0:
|
||||
if curr_sec is not None: diff_lines.append("")
|
||||
diff_lines.append(f"{s_id}. {txt}")
|
||||
curr_sec = s_id
|
||||
else:
|
||||
if s_id == sec_id and i_id == itm_id:
|
||||
line_str = f' <span class="line-through text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300 opacity-80">{s_id}.{i_id}. {txt} [УДАЛЕНИЕ]</span>' if action == "DELETE" else f' <span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300">{s_id}.{i_id}. {txt}</span>'
|
||||
else:
|
||||
line_str = f" {s_id}.{i_id}. {txt}"
|
||||
diff_lines.append(line_str)
|
||||
|
||||
diff_html = "\n".join(diff_lines)
|
||||
|
||||
db_set_session_state(session_id, "PROMPT_PREVIEW", {
|
||||
"draft_text": merged_prompt,
|
||||
"action": action,
|
||||
"section_id": sec_id,
|
||||
"item_id": itm_id,
|
||||
"content": content,
|
||||
"idle_turns": 0
|
||||
})
|
||||
|
||||
preview_reply = f"Предпросмотр изменений системного промпта:\n\n{diff_html}\n\nДля применения подтвердите действие, отредактируйте или отмените."
|
||||
db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1)
|
||||
return preview_reply, db_get_chat_history(session_id), {
|
||||
"type": "PROMPT_PREVIEW",
|
||||
"raw_draft": merged_prompt,
|
||||
"baseline_prompt": baseline_prompt,
|
||||
"buttons": [
|
||||
{"label": "Подтвердить", "value": "подтверждаю", "style": "primary"},
|
||||
{"label": "Отменить", "value": "отмена", "style": "danger"},
|
||||
{"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"}
|
||||
]
|
||||
}
|
||||
|
||||
# 4. Снапшоты СКУД
|
||||
elif fn_name == "db_get_snapshots":
|
||||
snapshots_res = db_get_snapshots(session_id=session_id, date_str=fn_args.get("date_str"), original_user_message=user_message)
|
||||
query_date = snapshots_res.get("query_date", "выбранную дату")
|
||||
reply_text = f"Реестр срезов СКУД за {query_date}:"
|
||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||||
return reply_text, db_get_chat_history(session_id), {
|
||||
"type": "SNAPSHOTS_CARD",
|
||||
"data": snapshots_res
|
||||
}
|
||||
|
||||
elif fn_name == "db_delete_snapshots":
|
||||
raw_id = fn_args.get("snapshot_id") or fn_args.get("day_str")
|
||||
raw_ids = fn_args.get("snapshot_ids") or []
|
||||
is_confirmed = fn_args.get("confirmed", False)
|
||||
|
||||
if raw_id and not raw_ids:
|
||||
if isinstance(raw_id, str) and "," in raw_id:
|
||||
raw_ids = [s.strip() for s in raw_id.split(",")]
|
||||
else:
|
||||
raw_ids = [raw_id]
|
||||
|
||||
safe_ids = [s.strip() for s in raw_ids if s and not str(s).strip().startswith("Y")]
|
||||
if not safe_ids:
|
||||
reply_text = "⚠️ Итоговый срез Y защищен от удаления. Выберите дневные снапшоты."
|
||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||||
return reply_text, db_get_chat_history(session_id), None
|
||||
|
||||
# --- 4.3. Реляционное изменение промпта (ADD / UPDATE / DELETE) ---
|
||||
elif fn_name == "db_prompt_node_edit":
|
||||
action = fn_args.get("action", "ADD").upper()
|
||||
sec_id = int(fn_args.get("section_id", 2))
|
||||
itm_id = int(fn_args.get("item_id", 1))
|
||||
content = fn_args.get("content", "").strip()
|
||||
baseline_prompt = db_get_active_system_prompt()
|
||||
|
||||
with get_db_connection() as temp_conn:
|
||||
temp_cursor = temp_conn.cursor()
|
||||
temp_cursor.execute("""
|
||||
SELECT section_id, item_id, content
|
||||
FROM system_prompt_nodes
|
||||
WHERE prompt_name = 'main_agent' AND is_active = 1
|
||||
ORDER BY section_id, item_id
|
||||
""")
|
||||
existing_nodes = temp_cursor.fetchall()
|
||||
|
||||
nodes_dict = {(sec, itm): txt for sec, itm, txt in existing_nodes}
|
||||
|
||||
if action == "DELETE":
|
||||
nodes_dict_for_draft = {k: v for k, v in nodes_dict.items() if k != (sec_id, itm_id)}
|
||||
else:
|
||||
nodes_dict_for_draft = dict(nodes_dict)
|
||||
nodes_dict_for_draft[(sec_id, itm_id)] = content
|
||||
|
||||
# Формирование чистого текста для редактора
|
||||
draft_lines = []
|
||||
curr_sec = None
|
||||
for (s_id, i_id), txt in sorted(nodes_dict_for_draft.items()):
|
||||
if i_id == 0:
|
||||
if curr_sec is not None:
|
||||
draft_lines.append("")
|
||||
draft_lines.append(f"{s_id}. {txt}")
|
||||
curr_sec = s_id
|
||||
else:
|
||||
draft_lines.append(f" {s_id}.{i_id}. {txt}")
|
||||
merged_prompt = "\n".join(draft_lines)
|
||||
|
||||
# Формирование HTML Diff с подсветкой
|
||||
diff_lines = []
|
||||
curr_sec = None
|
||||
display_nodes = dict(nodes_dict)
|
||||
if action != "DELETE":
|
||||
display_nodes[(sec_id, itm_id)] = content
|
||||
|
||||
for (s_id, i_id), txt in sorted(display_nodes.items()):
|
||||
if i_id == 0:
|
||||
if curr_sec is not None:
|
||||
diff_lines.append("")
|
||||
diff_lines.append(f"{s_id}. {txt}")
|
||||
curr_sec = s_id
|
||||
else:
|
||||
if s_id == sec_id and i_id == itm_id:
|
||||
if action == "DELETE":
|
||||
line_str = f' <span class="line-through text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300 opacity-80">{s_id}.{i_id}. {txt} [УДАЛЕНИЕ]</span>'
|
||||
else:
|
||||
line_str = f' <span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300">{s_id}.{i_id}. {txt}</span>'
|
||||
else:
|
||||
line_str = f" {s_id}.{i_id}. {txt}"
|
||||
diff_lines.append(line_str)
|
||||
|
||||
diff_html = "\n".join(diff_lines)
|
||||
|
||||
# Фиксация предпросмотра в session_states
|
||||
db_set_session_state(session_id, "PROMPT_PREVIEW", {
|
||||
"draft_text": merged_prompt,
|
||||
"action": action,
|
||||
"section_id": sec_id,
|
||||
"item_id": itm_id,
|
||||
"content": content,
|
||||
if not is_confirmed:
|
||||
query_date = state_data.get("query_date", "")
|
||||
db_set_session_state(session_id, "SNAPSHOT_DELETE_CONFIRM", {
|
||||
"snapshot_ids": safe_ids,
|
||||
"query_date": query_date,
|
||||
"idle_turns": 0
|
||||
})
|
||||
|
||||
with get_db_connection() as conn_fix:
|
||||
conn_fix.cursor().execute("""
|
||||
UPDATE chat_messages
|
||||
SET is_ephemeral = 1
|
||||
WHERE id = (SELECT MAX(id) FROM chat_messages WHERE session_id = ? AND role = 'user')
|
||||
""", (session_id,))
|
||||
conn_fix.commit()
|
||||
|
||||
preview_reply = (
|
||||
f"Предпросмотр изменений системного промпта:\n\n"
|
||||
f"{diff_html}\n\n"
|
||||
f"Для применения подтвердите действие, отредактируйте или отмените."
|
||||
)
|
||||
|
||||
db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1)
|
||||
return preview_reply, db_get_chat_history(session_id), {
|
||||
"type": "PROMPT_PREVIEW",
|
||||
"raw_draft": merged_prompt,
|
||||
"baseline_prompt": baseline_prompt,
|
||||
ids_str = ", ".join(safe_ids)
|
||||
reply_text = f"Вы действительно хотите удалить дневные снапшоты: {ids_str}?"
|
||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||||
return reply_text, db_get_chat_history(session_id), {
|
||||
"type": "SNAPSHOT_DELETE_CONFIRM",
|
||||
"buttons": [
|
||||
{"label": "Подтвердить", "value": "подтверждаю", "style": "primary"},
|
||||
{"label": "Отменить", "value": "отмена", "style": "danger"},
|
||||
{"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"}
|
||||
{"label": f"Удалить ({len(safe_ids)} шт.)", "value": f"подтверждаю удаление снапшотов {ids_str}", "style": "danger"},
|
||||
{"label": "Отмена", "value": "отмена", "style": "secondary"}
|
||||
]
|
||||
}
|
||||
else:
|
||||
db_delete_snapshots(snapshot_ids=safe_ids)
|
||||
query_date = state_data.get("query_date", "")
|
||||
updated_snapshots_res = db_get_snapshots(session_id=session_id, date_str=query_date)
|
||||
|
||||
# --- 4.4. Сервисные инструменты ---
|
||||
elif fn_name == "db_get_snapshots":
|
||||
snapshots_res = db_get_snapshots(session_id=session_id, date_str=fn_args.get("date_str"), original_user_message=user_message)
|
||||
tool_result_content = json.dumps(snapshots_res, ensure_ascii=False)
|
||||
reply_text = f"✅ Успешно удалено снапшотов: {len(safe_ids)} шт."
|
||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||||
return reply_text, db_get_chat_history(session_id), {
|
||||
"type": "SNAPSHOTS_CARD",
|
||||
"data": updated_snapshots_res
|
||||
}
|
||||
|
||||
elif fn_name == "db_delete_snapshots":
|
||||
raw_id = fn_args.get("snapshot_id") or fn_args.get("day_str")
|
||||
raw_ids = fn_args.get("snapshot_ids") or ([raw_id] if raw_id else [])
|
||||
|
||||
# Защита: итоговые Y-срезы удалять запрещено
|
||||
safe_ids = [s for s in raw_ids if not str(s).startswith("Y")]
|
||||
if not safe_ids:
|
||||
reply_text = "⚠️ Итоговый срез Y защищен от удаления. Выберите дневные снапшоты."
|
||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||||
return reply_text, db_get_chat_history(session_id), None
|
||||
|
||||
elif fn_name == "db_get_current_server_time":
|
||||
tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False)
|
||||
# Прямое выполнение удаления в SQLite без лишних текстовых подтверждений
|
||||
del_res = db_delete_snapshots(snapshot_ids=safe_ids)
|
||||
|
||||
# Получаем свежий реестр за ту же дату
|
||||
query_date = fn_args.get("day_str", "")
|
||||
updated_snapshots_res = db_get_snapshots(session_id=session_id, date_str=query_date)
|
||||
|
||||
elif fn_name == "db_get_stats":
|
||||
tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False)
|
||||
reply_text = f"✅ Успешно удалено снапшотов: {len(safe_ids)} шт."
|
||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||||
return reply_text, db_get_chat_history(session_id), {
|
||||
"type": "SNAPSHOTS_CARD",
|
||||
"data": updated_snapshots_res
|
||||
}
|
||||
|
||||
elif fn_name == "db_get_anomalies":
|
||||
tool_result_content = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False)
|
||||
# 5. Сервисные запросы
|
||||
elif fn_name == "db_get_current_server_time":
|
||||
tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False)
|
||||
elif fn_name == "db_get_stats":
|
||||
tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False)
|
||||
elif fn_name == "db_get_anomalies":
|
||||
tool_result_content = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False)
|
||||
elif fn_name == "db_get_rules":
|
||||
tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False)
|
||||
elif fn_name == "db_get_reference":
|
||||
tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False)
|
||||
else:
|
||||
tool_result_content = "{}"
|
||||
|
||||
elif fn_name == "db_get_rules":
|
||||
tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False)
|
||||
|
||||
elif fn_name == "db_get_reference":
|
||||
tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False)
|
||||
|
||||
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"))
|
||||
tool_result_content = json.dumps(res, ensure_ascii=False)
|
||||
|
||||
elif fn_name == "db_update_task_status":
|
||||
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"))
|
||||
tool_result_content = json.dumps(res, ensure_ascii=False)
|
||||
|
||||
elif fn_name == "db_delete_task":
|
||||
res = db_delete_task(user_id=user_id, task_id=str(fn_args.get("task_id", "")).upper())
|
||||
tool_result_content = json.dumps(res, ensure_ascii=False)
|
||||
|
||||
elif fn_name == "db_delete_snapshots":
|
||||
res = db_delete_snapshots(snapshot_id=fn_args.get("snapshot_id"), day_str=fn_args.get("day_str"))
|
||||
tool_result_content = json.dumps(res, ensure_ascii=False)
|
||||
|
||||
messages.append({"role": "tool", "content": tool_result_content})
|
||||
|
||||
# Интерпретация результатов вызова инструментов
|
||||
messages.append(msg)
|
||||
messages.append({"role": "tool", "content": tool_result_content})
|
||||
sec_msg = call_ollama_chat(messages, is_vision=False)
|
||||
raw_content = sec_msg.get("content", "").strip().replace("**", "").replace("*", "")
|
||||
final_content = clean_raw_tool_tags(clean_output(raw_content))
|
||||
final_content = clean_raw_tool_tags(clean_output(raw_content)) or "Запрос выполнен."
|
||||
|
||||
is_output_ephemeral = 1 if any(name in ["db_get_system_prompt", "db_get_system_prompts", "db_prompt_node_edit"] for name in tool_names_called) else 0
|
||||
for artifact in ["почемучка,", "почемучка!", "почемучка?", "почемучка", "почемучто", "почему-то"]:
|
||||
if final_content.lower().startswith(artifact):
|
||||
final_content = final_content[len(artifact):].lstrip(",.!?:; -")
|
||||
|
||||
db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_output_ephemeral)
|
||||
is_ephem = 1 if fn_name in ["db_get_snapshots", "db_get_system_prompt", "db_prompt_node_edit"] else 0
|
||||
db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_ephem)
|
||||
return final_content, db_get_chat_history(session_id), None
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# БЛОК 5: ОБЫЧНЫЙ ТЕКСТОВЫЙ ОТВЕТ И СЕМАНТИЧЕСКИЙ TOPIC DRIFT GUARD
|
||||
# =====================================================================
|
||||
# ANCHOR[AGENT_TOPIC_DRIFT]
|
||||
raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "")
|
||||
final_reply = clean_raw_tool_tags(clean_output(raw_str)) or "Запрос обработан."
|
||||
|
||||
for artifact in ["почемучка,", "почемучка!", "почемучка?", "почемучка", "почемучто", "почему-то"]:
|
||||
if final_reply.lower().startswith(artifact):
|
||||
final_reply = final_reply[len(artifact):].lstrip(",.!?:; -")
|
||||
|
||||
action_payload = None
|
||||
|
||||
# Обработка отвлечений оператора при активной сессии настройки/просмотра
|
||||
if session_state and session_state.get("state_type") in ["PROMPT_FOLLOWUP", "PROMPT_PREVIEW"]:
|
||||
idle_turns += 1
|
||||
logger.info(f"Topic Drift: активна сессия {session_state.get('state_type')}, шагов отвлечения: {idle_turns}/3")
|
||||
|
||||
# Порог отвлечений превышен (> 2 шагов после напоминания) -> Полная зачистка
|
||||
if idle_turns > 3:
|
||||
db_clear_session_state(session_id)
|
||||
deleted_count = db_purge_ephemeral_messages(session_id)
|
||||
logger.info(f"Topic Drift Guard TTL: сессия закрыта по таймауту, очищено {deleted_count} сообщений.")
|
||||
db_purge_ephemeral_messages(session_id)
|
||||
action_payload = None
|
||||
session_state = None
|
||||
elif idle_turns == 3:
|
||||
# На 3-м сообщении стороннего диалога вежливо спрашиваем оператора
|
||||
tool_action = db_get_tool_action("db_confirm_prompt_preview")
|
||||
guard_question = tool_action.get("follow_up_question", "Желаете продолжить работу с системным промптом?") if tool_action else "Желаете продолжить работу с системным промптом?"
|
||||
buttons = tool_action.get("buttons", []) if tool_action else []
|
||||
|
||||
final_reply += f"\n\n💡 *Напоминание:* {guard_question}"
|
||||
action_payload = {
|
||||
"type": "PROMPT_FOLLOWUP",
|
||||
"buttons": buttons
|
||||
}
|
||||
action_payload = {"type": "PROMPT_FOLLOWUP", "buttons": buttons}
|
||||
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": idle_turns})
|
||||
else:
|
||||
# Шаги 1 и 2: продолжаем обычный диалог, инкрементируя счетчик
|
||||
db_set_session_state(session_id, session_state.get("state_type"), {"idle_turns": idle_turns})
|
||||
|
||||
db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=(1 if session_state else 0))
|
||||
db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0)
|
||||
return final_reply, db_get_chat_history(session_id), action_payload
|
||||
|
||||
# Обработка исключений
|
||||
except Exception as ex:
|
||||
logger.exception(f"Непредвиденная ошибка агента: {ex}")
|
||||
error_reply = f"Внутренняя ошибка сервера: {ex}"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: modules/web_api/llm/core/context_manager.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: web_api / llm / core
|
||||
ROLE: Интеллектуальный менеджер контекста: разделение служебных транзакций
|
||||
инструментов и содержательного диалога с сохранением Topic Drift.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
from ..db.connection import get_db_connection
|
||||
from ..db_tools import (
|
||||
db_save_chat_message,
|
||||
db_get_chat_history,
|
||||
db_purge_ephemeral_messages,
|
||||
db_clear_session_state,
|
||||
db_set_session_state
|
||||
)
|
||||
|
||||
logger = logging.getLogger("CONTEXT_MANAGER")
|
||||
|
||||
|
||||
def save_tool_interaction(session_id: str, user_content: str, assistant_reply: str) -> None:
|
||||
"""
|
||||
Сохраняет синхронную служебную пару инструмента (и вопрос, и ответ = 1).
|
||||
При очистке удалятся оба сообщения, не оставляя сирот.
|
||||
"""
|
||||
db_save_chat_message(session_id, "user", user_content, is_ephemeral=1)
|
||||
db_save_chat_message(session_id, "assistant", assistant_reply, is_ephemeral=1)
|
||||
|
||||
|
||||
def save_dialog_interaction(session_id: str, user_content: str, assistant_reply: str) -> None:
|
||||
"""
|
||||
Сохраняет содержательный диалог пользователя и ассистента (и вопрос, и ответ = 0).
|
||||
Эти сообщения остаются в истории навсегда (включая Topic Drift).
|
||||
"""
|
||||
db_save_chat_message(session_id, "user", user_content, is_ephemeral=0)
|
||||
db_save_chat_message(session_id, "assistant", assistant_reply, is_ephemeral=0)
|
||||
|
||||
|
||||
def mark_last_user_message_ephemeral(session_id: str) -> None:
|
||||
"""Помечает последнее сообщение пользователя как эфемерное при активации инструмента."""
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
UPDATE chat_messages
|
||||
SET is_ephemeral = 1
|
||||
WHERE id = (SELECT MAX(id) FROM chat_messages WHERE session_id = ? AND role = 'user')
|
||||
""", (session_id,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def close_tool_session_and_cleanup(session_id: str, close_reason: str = "COMPLETED") -> int:
|
||||
"""
|
||||
Закрывает сессию инструмента и удаляет ТОЛЬКО служебные карточки/команды.
|
||||
Весь содержательный диалог сохраняется.
|
||||
"""
|
||||
db_clear_session_state(session_id)
|
||||
deleted_count = db_purge_ephemeral_messages(session_id)
|
||||
logger.info(f"[ContextManager] Сессия инструмента закрыта ({close_reason}). Очищено служебных сообщений: {deleted_count}")
|
||||
return deleted_count
|
||||
@@ -3,104 +3,166 @@
|
||||
FILE: modules/web_api/llm/core/fast_path.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: web_api / llm / core
|
||||
ROLE: Двухфазный жизненный цикл сессии (PROMPT_PREVIEW -> PROMPT_FOLLOWUP),
|
||||
атомарная фиксация узлов и мягкая зачистка эфемерного контекста.
|
||||
ROLE: Детерминированный мгновенный перехват нажатий кнопок подтверждения
|
||||
(без задержек LLM и обращения к Ollama).
|
||||
|
||||
AI-CONTEXT-ANCHORS:
|
||||
- ANCHOR[FAST_PATH_MAIN]: Точка входа handle_fast_path_intercept.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any, Tuple, Optional
|
||||
|
||||
from llm.db_tools import (
|
||||
db_apply_prompt_node_action,
|
||||
db_add_system_prompt,
|
||||
db_set_session_state,
|
||||
db_clear_session_state,
|
||||
db_purge_ephemeral_messages,
|
||||
db_save_chat_message,
|
||||
db_get_chat_history
|
||||
)
|
||||
# Прямые вызовы чистых доменных сервисов
|
||||
from services.prompts.service import save_full_prompt_draft, apply_prompt_action, get_active_system_prompt
|
||||
from services.tasks.service import get_tasks, delete_task
|
||||
from services.snapshots.service import get_snapshots_registry, delete_snapshots_safely
|
||||
|
||||
# Чат и сессии
|
||||
from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history, db_purge_ephemeral_messages
|
||||
from modules.web_api.llm.db.db_prompts import db_set_session_state, db_clear_session_state, db_get_tool_action
|
||||
from modules.web_api.llm.core.context_manager import close_tool_session_and_cleanup
|
||||
|
||||
logger = logging.getLogger("FAST_PATH")
|
||||
|
||||
|
||||
# ANCHOR[FAST_PATH_MAIN]
|
||||
def handle_fast_path_intercept(
|
||||
session_id: str,
|
||||
user_message: str,
|
||||
full_user_content: str,
|
||||
session_id: str,
|
||||
user_message: str,
|
||||
full_user_content: str,
|
||||
session_state: Optional[Dict[str, Any]]
|
||||
) -> Optional[Tuple[str, list, Optional[Dict[str, Any]]]]:
|
||||
"""Обрабатывает команды подтверждения, отмены и завершения сессии."""
|
||||
"""
|
||||
Мгновенный перехват нажатий кнопок подтверждения (Fast-Path).
|
||||
Возвращает (reply, history, action_type) или None, если требуется передать управление LLM.
|
||||
"""
|
||||
if not session_state:
|
||||
return None
|
||||
|
||||
state_type = session_state.get("state_type")
|
||||
user_msg_clean = user_message.lower().strip(" .!?:;")
|
||||
state_data = session_state.get("data_json") or {}
|
||||
if not isinstance(state_data, dict):
|
||||
state_data = {}
|
||||
|
||||
# =========================================================================
|
||||
# ФАЗА 1: ОБРАБОТКА В СОСТОЯНИИ PROMPT_PREVIEW
|
||||
# =========================================================================
|
||||
msg_lower = user_message.strip().lower()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 1. ПОДТВЕРЖДЕНИЕ ПРЕВЬЮ СИСТЕМНОГО ПРОМПТА
|
||||
# -------------------------------------------------------------------------
|
||||
if state_type == "PROMPT_PREVIEW":
|
||||
state_data = session_state.get("data_json") or {}
|
||||
if not isinstance(state_data, dict):
|
||||
state_data = {}
|
||||
is_confirm = msg_lower in ["подтверждаю", "да", "сохраняй", "применить", "ок", "подтвердить"]
|
||||
is_cancel = msg_lower in ["отмена", "отменить", "нет", "отклонить"]
|
||||
|
||||
draft_text = state_data.get("draft_text", "")
|
||||
action = state_data.get("action")
|
||||
sec_id = state_data.get("section_id")
|
||||
itm_id = state_data.get("item_id")
|
||||
content = state_data.get("content", "")
|
||||
if is_confirm:
|
||||
action = state_data.get("action", "MANUAL_EDIT")
|
||||
draft_text = state_data.get("draft_text", "")
|
||||
|
||||
# 1. ПОДТВЕРДИТЬ ИЗМЕНЕНИЯ -> ПЕРЕХОД В PROMPT_FOLLOWUP
|
||||
if user_msg_clean in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]:
|
||||
if action == "MANUAL_EDIT" or not action or sec_id is None:
|
||||
if draft_text:
|
||||
db_add_system_prompt("main_agent", draft_text)
|
||||
# Применение изменений
|
||||
if action == "MANUAL_EDIT" and draft_text:
|
||||
save_full_prompt_draft(draft_text, prompt_name="main_agent")
|
||||
else:
|
||||
db_apply_prompt_node_action(action=action, section_id=sec_id, item_id=itm_id, content=content)
|
||||
sec_id = state_data.get("section_id")
|
||||
itm_id = state_data.get("item_id")
|
||||
content = state_data.get("content", "")
|
||||
if sec_id is not None and itm_id is not None:
|
||||
apply_prompt_action(action=action, section_id=sec_id, item_id=itm_id, content=content)
|
||||
elif draft_text:
|
||||
save_full_prompt_draft(draft_text, prompt_name="main_agent")
|
||||
|
||||
logger.info("Системный промпт применен в БД. Переход в PROMPT_FOLLOWUP.")
|
||||
# Закрытие сессии и зачистка
|
||||
close_tool_session_and_cleanup(session_id, close_reason="PROMPT_APPLIED_SUCCESSFULLY")
|
||||
db_clear_session_state(session_id)
|
||||
|
||||
tool_action = db_get_tool_action("db_confirm_prompt_preview")
|
||||
reply = tool_action.get("success_template", "✅ Системный промпт успешно сохранен и применен в базе данных.") if tool_action else "✅ Системный промпт успешно сохранен и применен в базе данных."
|
||||
|
||||
# Переводим сессию в режим ожидания продолжения/завершения
|
||||
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {})
|
||||
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
|
||||
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
|
||||
return reply, db_get_chat_history(session_id), None
|
||||
|
||||
reply_text = "Системный промпт успешно сохранен и применен в базе данных. Желаете продолжить работу с системным промптом или завершить?"
|
||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||||
elif is_cancel:
|
||||
close_tool_session_and_cleanup(session_id, close_reason="PROMPT_EDIT_CANCELLED")
|
||||
db_clear_session_state(session_id)
|
||||
|
||||
action_payload = {
|
||||
"type": "PROMPT_FOLLOWUP",
|
||||
"buttons": [
|
||||
{"label": "✨ Показать обновленный промпт", "value": "покажи системный промпт", "style": "secondary"},
|
||||
{"label": "🏁 Завершить работу", "value": "завершить работу", "style": "primary"}
|
||||
]
|
||||
tool_action = db_get_tool_action("db_cancel_prompt_preview")
|
||||
reply = tool_action.get("success_template", "❌ Изменения системного промпта отменены.") if tool_action else "❌ Изменения системного промпта отменены."
|
||||
|
||||
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
|
||||
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
|
||||
return reply, db_get_chat_history(session_id), None
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 2. ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ СНАПШОТОВ СКУД
|
||||
# -------------------------------------------------------------------------
|
||||
elif state_type == "SNAPSHOT_DELETE_CONFIRM":
|
||||
is_confirm = "подтверждаю удаление снапшот" in msg_lower or msg_lower in ["подтверждаю", "да", "удалить"]
|
||||
is_cancel = msg_lower in ["отмена", "отменить", "нет"]
|
||||
|
||||
if is_confirm:
|
||||
snap_ids = state_data.get("snapshot_ids", [])
|
||||
query_date = state_data.get("query_date", "")
|
||||
|
||||
# Безопасное удаление через сервис
|
||||
del_res = delete_snapshots_safely(snapshot_ids=snap_ids)
|
||||
|
||||
close_tool_session_and_cleanup(session_id, close_reason="SNAPSHOTS_DELETED")
|
||||
|
||||
# Получаем свежий список за ту же дату
|
||||
updated_data = get_snapshots_registry(date_str=query_date if query_date else None)
|
||||
db_set_session_state(session_id, "SNAPSHOTS_VIEW", updated_data)
|
||||
|
||||
reply = f"✅ Успешно удалено снапшотов: {len(snap_ids)} шт."
|
||||
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1)
|
||||
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
|
||||
|
||||
return reply, db_get_chat_history(session_id), {
|
||||
"type": "SNAPSHOTS_CARD",
|
||||
"data": updated_data
|
||||
}
|
||||
return reply_text, db_get_chat_history(session_id), action_payload
|
||||
|
||||
# 2. ОТМЕНА В МОМЕНТ ПРЕВЬЮ -> ПОЛНАЯ ЗАЧИСТКА
|
||||
elif user_msg_clean in ["отмена", "отменить", "отклонить", "назад", "стоп"]:
|
||||
elif is_cancel:
|
||||
close_tool_session_and_cleanup(session_id, close_reason="SNAPSHOT_DELETE_CANCELLED")
|
||||
db_clear_session_state(session_id)
|
||||
deleted_count = db_purge_ephemeral_messages(session_id)
|
||||
logger.info(f"Изменения промпта отменены. Удалено эфемерных сообщений: {deleted_count}")
|
||||
|
||||
reply = "❌ Удаление снапшотов отменено."
|
||||
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
|
||||
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
|
||||
return reply, db_get_chat_history(session_id), None
|
||||
|
||||
reply_text = "Изменения системного промпта отменены. Контекст диалога чист."
|
||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0)
|
||||
return reply_text, db_get_chat_history(session_id), None
|
||||
# -------------------------------------------------------------------------
|
||||
# 3. ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ ЗАДАЧИ
|
||||
# -------------------------------------------------------------------------
|
||||
elif state_type == "TASK_DELETE_CONFIRM":
|
||||
is_confirm = "подтверждаю удаление задачи" in msg_lower or msg_lower in ["подтверждаю", "да", "удалить"]
|
||||
is_cancel = msg_lower in ["отмена", "отменить", "нет"]
|
||||
|
||||
# =========================================================================
|
||||
# ФАЗА 2: ОБРАБОТКА В СОСТОЯНИИ PROMPT_FOLLOWUP
|
||||
# =========================================================================
|
||||
elif state_type == "PROMPT_FOLLOWUP":
|
||||
# Явный сигнал завершения работы
|
||||
if user_msg_clean in [
|
||||
"завершить работу", "завершить", "закончить", "готово", "всё",
|
||||
"все", "нет", "нет, спасибо", "спасибо", "хватит", "выйти"
|
||||
]:
|
||||
if is_confirm:
|
||||
task_id = state_data.get("task_id")
|
||||
delete_task(user_id=1, task_id=str(task_id))
|
||||
|
||||
close_tool_session_and_cleanup(session_id, close_reason="TASK_DELETED")
|
||||
db_clear_session_state(session_id)
|
||||
deleted_count = db_purge_ephemeral_messages(session_id)
|
||||
logger.info(f"Сессия работы с промптом закрыта. Очищено эфемерных сообщений: {deleted_count}")
|
||||
|
||||
reply_text = "Работа с системным промптом завершена. Чем могу помочь?"
|
||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0)
|
||||
return reply_text, db_get_chat_history(session_id), None
|
||||
raw_tasks = get_tasks(user_id=1)
|
||||
reply = f"🗑 Задача #{task_id} удалена."
|
||||
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1)
|
||||
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
|
||||
|
||||
return reply, db_get_chat_history(session_id), {
|
||||
"type": "TASK_INTERACTIVE_CARD",
|
||||
"tasks": raw_tasks
|
||||
}
|
||||
|
||||
elif is_cancel:
|
||||
close_tool_session_and_cleanup(session_id, close_reason="TASK_DELETE_CANCELLED")
|
||||
db_clear_session_state(session_id)
|
||||
|
||||
reply = "❌ Удаление задачи отменено."
|
||||
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
|
||||
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
|
||||
return reply, db_get_chat_history(session_id), None
|
||||
|
||||
return None
|
||||
@@ -14,16 +14,16 @@ from typing import Dict, Any, Optional
|
||||
|
||||
logger = logging.getLogger("OLLAMA_CLIENT")
|
||||
|
||||
OLLAMA_URL = "http://192.168.11.3:11434/api/chat"
|
||||
TEXT_MODEL = "qwen2.5:14b"
|
||||
OLLAMA_URL = "http://10.121.17.227:11434/api/chat"
|
||||
TEXT_MODEL = "qwen2.5:14b-instruct-q8_0"
|
||||
VISION_MODEL = "qwen2.5vl:7b-q8_0"
|
||||
|
||||
LLM_OPTIONS = {
|
||||
"num_predict": 8192,
|
||||
"num_ctx": 8192,
|
||||
"temperature": 0.1,
|
||||
"repeat_penalty": 1.1,
|
||||
"presence_penalty": 0.5,
|
||||
"temperature": 0.0,
|
||||
"repeat_penalty": 1.0,
|
||||
"presence_penalty": 0.0,
|
||||
"top_p": 0.9
|
||||
}
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: modules/web_api/llm/core/prompt_merger.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: web_api / llm / core
|
||||
ROLE: Алгоритмы парсинга, предпросмотра, слияния и визуального выделения правок.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
# ANCHOR[PROMPT_MERGER_IMPORTS]
|
||||
import re
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("PROMPT_MERGER")
|
||||
|
||||
|
||||
# ANCHOR[PROMPT_MERGE_ENGINE]
|
||||
def build_prompt_preview_merge(current_prompt: str, user_message: str, proposed_text: str) -> str:
|
||||
"""
|
||||
Вычисляет результирующий чистый текст промпта для записи в БД.
|
||||
"""
|
||||
user_msg_lower = user_message.lower()
|
||||
|
||||
# 1. Сценарий удаления пункта
|
||||
if any(w in user_msg_lower for w in ["удали", "стереть", "убрать", "вырежи", "удалить"]):
|
||||
target_num_match = re.search(r'\d+(\.\d+)*', user_message)
|
||||
target_num = target_num_match.group(0) if target_num_match else ""
|
||||
|
||||
lines = current_prompt.splitlines()
|
||||
if target_num:
|
||||
new_lines = [line for line in lines if not line.strip().startswith(f"{target_num}.")]
|
||||
else:
|
||||
new_lines = lines
|
||||
return "\n".join(new_lines)
|
||||
|
||||
# 2. Сценарий добавления или замены пункта
|
||||
if proposed_text:
|
||||
if len(proposed_text) < 500:
|
||||
clean_item = proposed_text.strip()
|
||||
for prefix in ["добавь пункт", "добавить пункт", "вставь пункт", "добавь"]:
|
||||
if prefix in clean_item.lower():
|
||||
clean_item = re.sub(prefix, "", clean_item, flags=re.IGNORECASE).strip(" .:")
|
||||
|
||||
lines = current_prompt.splitlines()
|
||||
new_lines = []
|
||||
inserted = False
|
||||
for line in lines:
|
||||
new_lines.append(line)
|
||||
if "3.3." in line and not inserted:
|
||||
item_str = clean_item if re.match(r'^\d+\.\d+\.', clean_item) else f"3.4. {clean_item}"
|
||||
new_lines.append(f" {item_str}")
|
||||
inserted = True
|
||||
if not inserted:
|
||||
new_lines.append(f" {clean_item}")
|
||||
return "\n".join(new_lines)
|
||||
|
||||
return proposed_text or current_prompt
|
||||
|
||||
|
||||
def highlight_prompt_diff(old_prompt: str, new_prompt: str) -> str:
|
||||
"""
|
||||
Генерирует текст с подсветкой добавленных/измененных строк HTML-классами Tailwind.
|
||||
"""
|
||||
old_lines_set = {line.strip() for line in old_prompt.splitlines() if line.strip()}
|
||||
diff_lines = []
|
||||
|
||||
for line in new_prompt.splitlines():
|
||||
if line.strip() and line.strip() not in old_lines_set:
|
||||
# Выделяем новую или измененную строку красным цветом
|
||||
diff_lines.append(f'<span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-200">{line}</span>')
|
||||
else:
|
||||
diff_lines.append(line)
|
||||
|
||||
return "\n".join(diff_lines)
|
||||
@@ -3,9 +3,14 @@
|
||||
FILE: modules/web_api/llm/core/tool_injector.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: web_api / llm / core
|
||||
ROLE: Санитарная очистка сырых артефактов и тегов из ответов LLM.
|
||||
ROLE: Семантический анализ намерений оператора (Intent Classifier) и
|
||||
детерминированная сборка вызовов инструментов при сбоях нативного Function Calling.
|
||||
|
||||
AI-CONTEXT-ANCHORS:
|
||||
- ANCHOR[INTENT_INJECTOR_MAIN]: Точка входа inject_tools_if_needed.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
from typing import List, Dict, Any
|
||||
@@ -14,37 +19,120 @@ logger = logging.getLogger("TOOL_INJECTOR")
|
||||
|
||||
|
||||
def clean_raw_tool_tags(text: str) -> str:
|
||||
"""Удаляет сырые теги вызова инструментов, если модель случайно вывела их в текст."""
|
||||
"""Удаляет сырые теги вызова инструментов и системный шум."""
|
||||
if not text:
|
||||
return ""
|
||||
text = re.sub(r'\{"name":\s*"db_[^}]+\}\s*(</tool_call>)?', '', text)
|
||||
text = re.sub(r'<tool_call>.*?</tool_call>', '', text, flags=re.DOTALL)
|
||||
text = re.sub(r'</tool_call>\w*\[\]\(\)', '', text)
|
||||
text = re.sub(r'</tool_call>', '', text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def clean_output(text: str) -> str:
|
||||
"""Удаляет слова-паразиты и склейки в начале ответа."""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
artifacts = [
|
||||
"почемучка", "почемучка,", "почемучка!", "почемучка?",
|
||||
"почемучто", "почто", "почему что", "почему-то",
|
||||
"здравствуйте!", "привет!"
|
||||
]
|
||||
|
||||
cleaned = text.strip()
|
||||
for art in artifacts:
|
||||
if cleaned.lower().startswith(art):
|
||||
cleaned = cleaned[len(art):].lstrip(",.!?:; -")
|
||||
|
||||
cleaned = re.sub(r'<tool_call>.*?</tool_call>', '', text, flags=re.DOTALL)
|
||||
cleaned = re.sub(r'<\|.*?\|>', '', cleaned)
|
||||
return cleaned.strip()
|
||||
|
||||
|
||||
def inject_tools_if_needed(user_message: str, raw_text_content: str, tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
def clean_output(text: str) -> str:
|
||||
"""Очищает маркеры форматирования."""
|
||||
return text.strip() if text else ""
|
||||
|
||||
|
||||
# ANCHOR[INTENT_INJECTOR_MAIN]
|
||||
def inject_tools_if_needed(user_message: str, raw_reply: str, existing_tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Проходной фильтр: все решения о вызове инструментов принимает исключительно LLM.
|
||||
Гибридный семантический анализатор:
|
||||
Если модель ответила текстом с сомнениями или пропустила tool_call,
|
||||
распознает доменное намерение и конструирует синтетический tool_call.
|
||||
"""
|
||||
return tool_calls
|
||||
if existing_tool_calls:
|
||||
return existing_tool_calls
|
||||
|
||||
msg_clean = user_message.strip().lower()
|
||||
|
||||
# 1. СЕМАНТИКА: Просмотр системного промпта
|
||||
# Паттерны: "покажи системный промпт", "выведи промпт", "текущий промпт", "какой сейчас системный промпт"
|
||||
if "промпт" in msg_clean and any(kw in msg_clean for kw in ["покажи", "выведи", "какой", "дай", "текст", "актуальн"]):
|
||||
logger.info("[IntentInjector] Распознано намерение просмотра системного промпта")
|
||||
return [{
|
||||
"function": {
|
||||
"name": "db_get_system_prompt",
|
||||
"arguments": {}
|
||||
}
|
||||
}]
|
||||
|
||||
# 2. СЕМАНТИКА: Удаление задач
|
||||
# Паттерны: "удали задачу 37", "убери 37 задачу", "сотри таску #37", "сними с повестки задачу 37"
|
||||
if any(kw in msg_clean for kw in ["удали", "удалить", "убери", "сотри", "сними"]) and any(kw in msg_clean for kw in ["задач", "таск"]):
|
||||
task_match = re.search(r'#?\s*(\d+)', msg_clean)
|
||||
if task_match:
|
||||
task_id = task_match.group(1)
|
||||
logger.info(f"[IntentInjector] Распознано намерение удаления задачи: #{task_id}")
|
||||
return [{
|
||||
"function": {
|
||||
"name": "db_tasks_edit",
|
||||
"arguments": {"action": "DELETE", "task_id": task_id}
|
||||
}
|
||||
}]
|
||||
|
||||
# 3. СЕМАНТИКА: Управление системным промптом (удаление и мульти-удаление)
|
||||
# Паттерны: "удали 1.8 и 3.4", "удали пункт 2.3", "вычеркни 1.8, 3.4 из промпта"
|
||||
if any(kw in msg_clean for kw in ["удали", "удалить", "убери", "вычеркни", "сотри"]) and not any(kw in msg_clean for kw in ["задач", "снапшот", "срез"]):
|
||||
node_matches = re.findall(r'(\d+)[\.\s]+(\d+)', user_message)
|
||||
if node_matches:
|
||||
formatted_nodes = [f"{s}.{i}" for s, i in node_matches]
|
||||
logger.info(f"[IntentInjector] Распознано намерение удаления узлов промпта: {formatted_nodes}")
|
||||
return [{
|
||||
"function": {
|
||||
"name": "db_prompt_node_edit",
|
||||
"arguments": {
|
||||
"action": "BATCH_DELETE" if len(formatted_nodes) > 1 else "DELETE",
|
||||
"section_id": int(node_matches[0][0]),
|
||||
"item_id": int(node_matches[0][1]),
|
||||
"nodes_list": formatted_nodes,
|
||||
"content": ""
|
||||
}
|
||||
}
|
||||
}]
|
||||
|
||||
# 4. СЕМАНТИКА: Добавление пункта промпта
|
||||
# Паттерны: "добавь 3.4 Текст", "добавь пункт 3.4 Текст", "впиши в 3.4 Текст"
|
||||
if any(kw in msg_clean for kw in ["добавь", "добавить", "впиши", "запиши"]) and not any(kw in msg_clean for kw in ["задач", "таск"]):
|
||||
add_match = re.search(r'(\d+)[\.\s]+(\d+)[\.\s\:\-]+(.*)', user_message)
|
||||
if add_match:
|
||||
sec_id = int(add_match.group(1))
|
||||
itm_id = int(add_match.group(2))
|
||||
content = add_match.group(3).strip()
|
||||
logger.info(f"[IntentInjector] Распознано намерение добавления узла промпта: {sec_id}.{itm_id}")
|
||||
return [{
|
||||
"function": {
|
||||
"name": "db_prompt_node_edit",
|
||||
"arguments": {
|
||||
"action": "ADD",
|
||||
"section_id": sec_id,
|
||||
"item_id": itm_id,
|
||||
"content": content
|
||||
}
|
||||
}
|
||||
}]
|
||||
|
||||
# 5. СЕМАНТИКА: Смена статуса задач
|
||||
if any(kw in msg_clean for kw in ["в работу", "начни", "стартуй", "за работу"]):
|
||||
task_match = re.search(r'#?\s*(\d+)', msg_clean)
|
||||
if task_match:
|
||||
task_id = task_match.group(1)
|
||||
logger.info(f"[IntentInjector] Распознано намерение взятия в работу задачи: #{task_id}")
|
||||
return [{
|
||||
"function": {
|
||||
"name": "db_tasks_edit",
|
||||
"arguments": {"action": "UPDATE", "task_id": task_id, "status": "IN_PROGRESS"}
|
||||
}
|
||||
}]
|
||||
|
||||
if any(kw in msg_clean for kw in ["заверши", "закрой", "готово", "выполнено"]):
|
||||
task_match = re.search(r'#?\s*(\d+)', msg_clean)
|
||||
if task_match:
|
||||
task_id = task_match.group(1)
|
||||
logger.info(f"[IntentInjector] Распознано намерение закрытия задачи: #{task_id}")
|
||||
return [{
|
||||
"function": {
|
||||
"name": "db_tasks_edit",
|
||||
"arguments": {"action": "UPDATE", "task_id": task_id, "status": "COMPLETED"}
|
||||
}
|
||||
}]
|
||||
|
||||
return existing_tool_calls
|
||||
@@ -1,49 +0,0 @@
|
||||
"""
|
||||
FILE: modules/web_api/llm/db/db_snapshots.py
|
||||
"""
|
||||
import json
|
||||
from typing import Dict, Any, Optional
|
||||
from .connection import get_db_connection
|
||||
from .db_prompts import db_set_session_state
|
||||
|
||||
def db_get_snapshots(session_id: str = "web_session_main", date_str: Optional[str] = None, original_user_message: str = "") -> Dict[str, Any]:
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
query = "SELECT snapshot_id, log_date, snapshot_time, COUNT(*) as record_count FROM scud_logs "
|
||||
params = []
|
||||
if date_str:
|
||||
iso_date = date_str
|
||||
if "." in date_str:
|
||||
parts = date_str.split(".")
|
||||
if len(parts) == 3:
|
||||
iso_date = f"{parts[2]}-{parts[1]}-{parts[0]}"
|
||||
query += " WHERE log_date = ? OR log_date = ? OR snapshot_time LIKE ? "
|
||||
params.extend([date_str, iso_date, f"{iso_date}%"])
|
||||
|
||||
query += " GROUP BY snapshot_id ORDER BY id DESC LIMIT 50"
|
||||
cursor.execute(query, params)
|
||||
rows = cursor.fetchall()
|
||||
snapshots = [dict(r) for r in rows]
|
||||
|
||||
result_data = {
|
||||
"query_date": date_str or "все",
|
||||
"snapshots_count": len(snapshots),
|
||||
"snapshots": snapshots
|
||||
}
|
||||
db_set_session_state(session_id=session_id, state_type="SNAPSHOTS_VIEW", data=json.dumps(result_data, ensure_ascii=False))
|
||||
conn.close()
|
||||
return result_data
|
||||
|
||||
def db_delete_snapshots(snapshot_id: Optional[str] = None, day_str: Optional[str] = None) -> Dict[str, Any]:
|
||||
if not snapshot_id and not day_str:
|
||||
return {"status": "error", "message": "Необходимо указать snapshot_id или day_str (ДД.ММ.ГГГГ)."}
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
if snapshot_id:
|
||||
cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,))
|
||||
else:
|
||||
cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (day_str, f"%{day_str.replace('.', '')}%"))
|
||||
deleted = cursor.rowcount
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"status": "success", "message": f"Успешно удалено записей: {deleted}"}
|
||||
@@ -1,82 +0,0 @@
|
||||
"""
|
||||
FILE: modules/web_api/llm/db/db_tasks.py
|
||||
"""
|
||||
import re
|
||||
from typing import List, Dict, Any, Optional
|
||||
from .connection import get_db_connection
|
||||
|
||||
def normalize_task_id(task_id_input: str) -> str:
|
||||
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}"
|
||||
|
||||
def db_get_tasks(user_id: int) -> List[Dict[str, Any]]:
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT id, task_id, module, title, priority, status, due_date, created_at
|
||||
FROM tasks
|
||||
WHERE user_id = ?
|
||||
ORDER BY id DESC
|
||||
""", (user_id,))
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def db_add_task(user_id: int, module: str, title: str, priority: str = "MEDIUM", due_date: Optional[str] = None) -> Dict[str, Any]:
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT MAX(id) FROM tasks")
|
||||
max_id = cursor.fetchone()[0] or 0
|
||||
new_task_id = f"TASK-{(max_id + 1):02d}"
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO tasks (task_id, module, title, priority, status, due_date, user_id)
|
||||
VALUES (?, ?, ?, ?, 'BACKLOG', ?, ?)
|
||||
""", (new_task_id, module, title, priority.upper(), due_date, user_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
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 = "COMPLETED", due_date: Optional[str] = None) -> Dict[str, Any]:
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
formatted_id = normalize_task_id(task_id)
|
||||
if due_date:
|
||||
cursor.execute("""
|
||||
UPDATE tasks
|
||||
SET status = ?, due_date = ?
|
||||
WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
|
||||
""", (status.upper(), due_date, formatted_id, f"%{task_id.strip()}", user_id))
|
||||
else:
|
||||
cursor.execute("""
|
||||
UPDATE tasks
|
||||
SET status = ?
|
||||
WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
|
||||
""", (status.upper(), formatted_id, f"%{task_id.strip()}", user_id))
|
||||
|
||||
if cursor.rowcount == 0:
|
||||
conn.close()
|
||||
return {"error": f"Задача {task_id} не найдена или принадлежит другому пользователю"}
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"status": "success", "message": f"Статус задачи {formatted_id} обновлен на {status.upper()}"}
|
||||
|
||||
def db_delete_task(user_id: int, task_id: str) -> Dict[str, Any]:
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
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:
|
||||
conn.close()
|
||||
return {"error": f"Задача {task_id} не найдена"}
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"status": "success", "message": f"Задача {formatted_id} удалена"}
|
||||
@@ -1,17 +1,53 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: modules/web_api/llm/db_tools.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: web_api / llm
|
||||
ROLE: Фасадная точка доступа к доменным сервисам (Domain Facade).
|
||||
|
||||
AI-CONTEXT-ANCHORS:
|
||||
- ANCHOR[FACADE_EXPORTS]: Экспорт методов предметных сервисов для LLM и API.
|
||||
===============================================================================
|
||||
"""
|
||||
from datetime import datetime
|
||||
from .db.connection import DB_PATH, get_db_connection
|
||||
from .db.db_chat import db_save_chat_message, db_get_chat_history, db_purge_ephemeral_messages
|
||||
from .db.db_tasks import normalize_task_id, db_get_tasks, db_add_task, db_update_task_status, db_delete_task
|
||||
from .db.db_snapshots import db_get_snapshots, db_delete_snapshots
|
||||
|
||||
# ANCHOR[FACADE_EXPORTS]
|
||||
from typing import Optional, List, Dict, Any
|
||||
from core.connection import DB_PATH, get_connection as get_db_connection
|
||||
|
||||
# Домен: Задачи
|
||||
from services.tasks.service import (
|
||||
get_tasks as db_get_tasks,
|
||||
add_task as db_add_task,
|
||||
update_task_details as db_update_task_details,
|
||||
delete_task as db_delete_task,
|
||||
execute_task_action as db_tasks_edit
|
||||
)
|
||||
from services.tasks.exporter import export_tasks_to_markdown as db_export_tasks_markdown
|
||||
from services.tasks.repository import normalize_task_id
|
||||
|
||||
# Домен: Системный промпт
|
||||
from services.prompts.service import (
|
||||
get_active_system_prompt as db_get_active_system_prompt,
|
||||
apply_prompt_action as db_apply_prompt_node_action,
|
||||
save_full_prompt_draft,
|
||||
create_prompt_preview
|
||||
)
|
||||
|
||||
# Домен: База знаний
|
||||
from services.knowledge.service import (
|
||||
get_rules as db_get_rules,
|
||||
add_rule as db_add_rule
|
||||
)
|
||||
|
||||
# Чат, сессии, статистика
|
||||
from .db.db_chat import (
|
||||
db_save_chat_message,
|
||||
db_get_chat_history,
|
||||
db_purge_ephemeral_messages,
|
||||
db_clear_chat_history
|
||||
)
|
||||
from .db.db_prompts import (
|
||||
db_get_active_system_prompt,
|
||||
db_add_system_prompt,
|
||||
db_apply_prompt_node_action,
|
||||
db_get_tool_action,
|
||||
db_get_rules,
|
||||
db_set_session_state,
|
||||
db_get_session_state,
|
||||
db_clear_session_state,
|
||||
@@ -19,13 +55,42 @@ from .db.db_prompts import (
|
||||
db_get_anomalies,
|
||||
db_get_reference
|
||||
)
|
||||
from .core.calendar_utils import get_dynamic_calendar_context as db_get_current_server_time
|
||||
|
||||
def db_get_current_server_time():
|
||||
now = datetime.now()
|
||||
days_ru = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"]
|
||||
return {
|
||||
"current_date": now.strftime("%d.%m.%Y"),
|
||||
"current_time": now.strftime("%H:%M:%S"),
|
||||
"day_of_week": days_ru[now.weekday()],
|
||||
"iso_date": now.strftime("%Y-%m-%d")
|
||||
}
|
||||
|
||||
def db_add_system_prompt(name_or_text: str, draft_text: str = None) -> None:
|
||||
"""Совместимая обертка для сохранения системного промпта."""
|
||||
if draft_text is not None:
|
||||
save_full_prompt_draft(draft_text, prompt_name=name_or_text)
|
||||
else:
|
||||
save_full_prompt_draft(name_or_text, prompt_name="main_agent")
|
||||
|
||||
|
||||
def db_get_snapshots(session_id: str = "web_session_main", date_str: str = None, original_user_message: str = "") -> Dict[str, Any]:
|
||||
"""Совместимый фасад выборки снапшотов с сохранением стейта сессии."""
|
||||
from services.snapshots.service import get_snapshots_registry
|
||||
from .core.calendar_utils import parse_relative_date_ru
|
||||
|
||||
clean_date = (date_str or "").strip()
|
||||
if not clean_date and original_user_message:
|
||||
clean_date = parse_relative_date_ru(original_user_message)
|
||||
|
||||
res = get_snapshots_registry(date_str=clean_date if clean_date else None)
|
||||
db_set_session_state(session_id=session_id, state_type="SNAPSHOTS_VIEW", data=res)
|
||||
return res
|
||||
|
||||
|
||||
def db_delete_snapshots(snapshot_id: str = None, snapshot_ids: List[str] = None, day_str: str = None) -> Dict[str, Any]:
|
||||
"""Совместимый фасад безопасного удаления снапшотов."""
|
||||
from services.snapshots.service import delete_snapshots_safely
|
||||
|
||||
target_ids = []
|
||||
if snapshot_ids:
|
||||
target_ids.extend(snapshot_ids)
|
||||
if snapshot_id:
|
||||
if isinstance(snapshot_id, str) and "," in snapshot_id:
|
||||
target_ids.extend([s.strip() for s in snapshot_id.split(",")])
|
||||
else:
|
||||
target_ids.append(snapshot_id)
|
||||
|
||||
return delete_snapshots_safely(snapshot_ids=target_ids)
|
||||
+126
-88
@@ -1,50 +1,82 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: modules/web_api/llm/schemas.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: web_api / llm
|
||||
ROLE: Декларативная схема нативных инструментов (Function Calling) для Ollama.
|
||||
|
||||
AI-CONTEXT-ANCHORS:
|
||||
- ANCHOR[SCHEMA_PROMPT_EDIT]: Схема управления узлами системного промпта.
|
||||
- ANCHOR[SCHEMA_TASKS_EDIT]: Консолидированная схема управления задачами.
|
||||
- ANCHOR[SCHEMA_SNAPSHOTS]: Схема доступа к логам и срезам СКУД.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
# ANCHOR[SCHEMA_PROMPT_EDIT]
|
||||
TOOLS_SCHEMA = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_prompt_node_edit",
|
||||
"description": (
|
||||
"ЕДИНСТВЕННЫЙ инструмент для добавления, изменения или УДАЛЕНИЯ пунктов системного промпта. "
|
||||
"Вызывай ВСЕГДА при фразах 'удали X.Y', 'удалить пункт X.Y', 'добавь X.Y', 'измени X.Y'. "
|
||||
"При удалении: action='DELETE', section_id=X, item_id=Y, content=''. "
|
||||
"Запрещено отвечать текстовым подтверждением без вызова этой функции!"
|
||||
"Управление элементами системного промпта (добавление, изменение, удаление пунктов).\n"
|
||||
"Поддерживает как одиночные пункты (section_id, item_id), так и список пунктов для удаления (nodes_list=['1.8', '3.4']).\n"
|
||||
"При любом запросе на удаление или добавление пунктов системного промпта ТЫ ОБЯЗАН вызвать этот инструмент."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["ADD", "UPDATE", "DELETE"],
|
||||
"description": "ADD (добавить), UPDATE (изменить), DELETE (удалить)"
|
||||
"enum": ["ADD", "EDIT", "DELETE", "BATCH_DELETE"],
|
||||
"description": "Тип действия"
|
||||
},
|
||||
"section_id": {
|
||||
"type": "integer",
|
||||
"description": "Номер раздела (например: 1, 2, 3)"
|
||||
"description": "Номер раздела (например 1)"
|
||||
},
|
||||
"item_id": {
|
||||
"type": "integer",
|
||||
"description": "Номер пункта (например: 4, 8, 9)"
|
||||
"description": "Номер пункта (например 8)"
|
||||
},
|
||||
"nodes_list": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Список пунктов для удаления/изменения, например ['1.8', '3.4']"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Текст пункта (для DELETE передается пустая строка)"
|
||||
"description": "Текст пункта"
|
||||
}
|
||||
},
|
||||
"required": ["action", "section_id", "item_id"]
|
||||
"required": ["action"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_get_system_prompt",
|
||||
"description": "Просмотр текущего активного системного промпта ассистента из базы данных.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
# ANCHOR[SCHEMA_TASKS_EDIT]
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_get_tasks",
|
||||
"description": "ПОЛУЧИТЬ СПИСОК ЗАДАЧ ТЕКУЩЕГО ПОЛЬЗОВАТЕЛЯ. Вызывай СРАЗУ при запросе 'покажи мои задачи' или 'список задач'. ВАЖНОЕ ПРАВИЛО ВЫВОДА: Выводи задачи ЕДИНЫМ плоским списком (нумерованным или маркированным) по порядку ID. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО группировать задачи по статусам (В процессе, Бэклог, Завершены) или создавать подзаголовки, если оператор явно не попросил о группировке!",
|
||||
"description": "Просмотр реестра задач и бэклога текущего пользователя.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "Фильтр статуса: BACKLOG, IN_PROGRESS или COMPLETED."
|
||||
"enum": ["ALL", "IN_PROGRESS", "PLANNED", "COMPLETED"],
|
||||
"description": "Опциональный фильтр статуса задач"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,64 +85,70 @@ TOOLS_SCHEMA = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_get_rules",
|
||||
"description": "ПОЛУЧИТЬ БАЗУ ЗНАНИЙ ИИ И ПРАВИЛА АРБИТРАЖА (ai_knowledge_base). Вызывай когда пользователь просит показать базу знаний, правила, инструкции или промпты.",
|
||||
"parameters": {"type": "object", "properties": {}}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_get_system_prompt",
|
||||
"description": "ВЫЗЫВАЙ ВСЕГДА при наличии в сообщении фраз: 'покажи системный промпт', 'покажи промпт', 'выведи промпт', 'системный промпт'. Запрещено отвечать текстом без вызова этого инструмента.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_update_task_status",
|
||||
"description": "Изменить статус и/или срок выполнения задачи в реестре.",
|
||||
"name": "db_tasks_edit",
|
||||
"description": (
|
||||
"Единый инструмент управления задачами: создание (ADD), изменение статуса/срока/названия (UPDATE), удаление (DELETE) и экспорт (EXPORT).\n"
|
||||
"СТРОГИЕ ПРАВИЛА ВЫЗОВА:\n"
|
||||
"- На любые фразы вида 'удали задачу N', 'удалить N', 'убери задачу N' ТЫ ОБЯЗАН СРАЗУ вызвать инструмент с action='DELETE', task_id='N'.\n"
|
||||
"- На фразы 'возьми в работу N' вызывай action='UPDATE', task_id='N', status='IN_PROGRESS'.\n"
|
||||
"- На фразы 'заверши N', 'готово N' вызывай action='UPDATE', task_id='N', status='COMPLETED'.\n"
|
||||
"- КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО писать текстовые вопросы или запрашивать подтверждения словами! ТЫ ОБЯЗАН СРАЗУ вызвать инструмент."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": {"type": "string", "description": "Идентификатор задачи, например TASK-17"},
|
||||
"status": {"type": "string", "description": "Новый статус: COMPLETED, IN_PROGRESS или BACKLOG"},
|
||||
"due_date": {"type": "string", "description": "Срок выполнения задачи"}
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["ADD", "UPDATE", "DELETE", "EXPORT"],
|
||||
"description": "Тип действия: ADD, UPDATE, DELETE или EXPORT"
|
||||
},
|
||||
"task_id": {
|
||||
"type": "string",
|
||||
"description": "Номер задачи (например: '37')"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Описание или текст задачи"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["IN_PROGRESS", "COMPLETED", "BACKLOG"],
|
||||
"description": "Новый статус задачи: IN_PROGRESS (В работу), COMPLETED (Завершено), BACKLOG (В планы)"
|
||||
},
|
||||
"priority": {
|
||||
"type": "string",
|
||||
"enum": ["LOW", "MEDIUM", "HIGH", "CRITICAL"],
|
||||
"description": "Приоритет задачи"
|
||||
},
|
||||
"module": {
|
||||
"type": "string",
|
||||
"description": "Модуль проекта"
|
||||
},
|
||||
"due_date": {
|
||||
"type": "string",
|
||||
"description": "Срок в формате ГГГГ-ММ-ДД"
|
||||
},
|
||||
"filename": {
|
||||
"type": "string",
|
||||
"description": "Имя файла для экспорта"
|
||||
}
|
||||
},
|
||||
"required": ["task_id"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_delete_task",
|
||||
"description": "Удалить задачу из реестра по её task_id (например, TASK-18).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": {"type": "string", "description": "Идентификатор задачи для удаления, например TASK-18"}
|
||||
},
|
||||
"required": ["task_id"]
|
||||
"required": ["action"]
|
||||
}
|
||||
}
|
||||
},
|
||||
# ANCHOR[SCHEMA_SNAPSHOTS]
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_get_snapshots",
|
||||
"description": "ПОЛУЧИТЬ АКТУАЛЬНЫЙ СПИСОК СНАПШОТОВ ИЗ БАЗЫ SQLITE. Вызывай ЭТУ ФУНКЦИЮ ВСЕГДА, даже если список снапшотов уже есть в истории чата или пользователь просит 'обновить', 'повторить запрос', 'проверить снова'. ЗАПРЕЩЕНО беречь контекст и выводить старые данные из истории!",
|
||||
"description": "Получение списка снапшотов и срезов логов СКУД из базы данных за конкретную дату.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"date_str": {
|
||||
"type": "string",
|
||||
"description": "Точная дата в формате ДД.ММ.ГГГГ (например, '12.08.2026'), взятая из [СИСТЕМНОГО КАЛЕНДАРЯ СЕРВЕРА]."
|
||||
"description": "Дата в формате ДД.ММ.ГГГГ или относительное слово"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,12 +158,18 @@ TOOLS_SCHEMA = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_delete_snapshots",
|
||||
"description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.",
|
||||
"description": "Удаление снапшотов СКУД по идентификатору или дате.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"},
|
||||
"day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"}
|
||||
"snapshot_id": {
|
||||
"type": "string",
|
||||
"description": "Идентификатор конкретного снапшота"
|
||||
},
|
||||
"day_str": {
|
||||
"type": "string",
|
||||
"description": "Дата всех снапшотов за день"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,29 +177,31 @@ TOOLS_SCHEMA = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_get_current_server_time",
|
||||
"description": "ПОЛУЧИТЬ ТЕКУЩУЮ ДАТУ, ВРЕМЯ И ДЕНЬ НЕДЕЛИ СЕРВЕРА. Вызывай МГНОВЕННО при любых вопросах пользователя про точное текущее время или текущую дату.",
|
||||
"name": "db_get_anomalies",
|
||||
"description": "Просмотр истории аномалий и расхождений между СКУД и 1С.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
"properties": {
|
||||
"date_str": {
|
||||
"type": "string",
|
||||
"description": "Опциональная дата в формате ДД.ММ.ГГГГ"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Лимит записей"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_add_task",
|
||||
"description": "Добавить новую задачу в бэклог проекта.",
|
||||
"name": "db_get_rules",
|
||||
"description": "Просмотр базы знаний и правил кадрового арбитража компании.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string", "description": "Краткое описание задачи"},
|
||||
"priority": {"type": "string", "description": "Приоритет: HIGH, MEDIUM, LOW"},
|
||||
"module": {"type": "string", "description": "Модуль проекта, например general или services/scud_export"},
|
||||
"due_date": {"type": "string", "description": "Срок выполнения задачи, например '2026-08-07 12:00'"}
|
||||
},
|
||||
"required": ["title"]
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -163,43 +209,35 @@ TOOLS_SCHEMA = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_get_stats",
|
||||
"description": "ПОЛУЧИТЬ ОБЩУЮ СТАТИСТИКУ БАЗЫ ДАННЫХ. Вызывай, когда пользователь просит показать общую статистику БД, количество записей в таблицах или размер базы.",
|
||||
"parameters": {"type": "object", "properties": {}}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_get_anomalies",
|
||||
"description": "ПОЛУЧИТЬ ИСТОРИЮ АНОМАЛИЙ СКУД ⟷ 1С. Вызывай при запросах на просмотр аномалий или расхождений. Передавай date_str если пользователь просит аномалии за конкретный день, или увеличенный limit (например 100) если просит все.",
|
||||
"description": "Получение статистики количества записей в таблицах базы данных.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {"type": "integer", "description": "Максимальное количество записей (по умолчанию 100)"},
|
||||
"date_str": {"type": "string", "description": "Опциональная дата в формате ДД.ММ.ГГГГ"}
|
||||
}
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_get_session_states",
|
||||
"description": "ПОЛУЧИТЬ АКТИВНЫЕ СЕССИИ И ПРЕВЬЮ (session_states). Вызывай, когда пользователь просит показать текущие сессии или статус превью.",
|
||||
"parameters": {"type": "object", "properties": {}}
|
||||
"name": "db_get_current_server_time",
|
||||
"description": "Получение текущего точного времени сервера.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_get_reference",
|
||||
"description": "ПОЛУЧИТЬ СИСТЕМНЫЙ СПРАВОЧНИК И ПРИМЕРЫ КОМАНД ДЛЯ ОПЕРАТОРА (system_reference). Вызывай ВСЕГДА, когда пользователь спрашивает про возможности ассистента, список команд, примерах промптов или справе по работе с системой.",
|
||||
"description": "Справка о возможностях ассистента и примеры доступных команд.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": "string",
|
||||
"description": "Фильтр категории: scud, tasks, calendar или system. Если просят всё — не передавай параметр."
|
||||
"description": "Категория справки"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,12 @@ CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
if CURRENT_DIR not in sys.path:
|
||||
sys.path.insert(0, CURRENT_DIR)
|
||||
|
||||
ROOT_DIR = os.path.abspath(os.path.join(CURRENT_DIR, "../../"))
|
||||
|
||||
for p in [ROOT_DIR, CURRENT_DIR]:
|
||||
if p not in sys.path:
|
||||
sys.path.insert(0, p)
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
@@ -25,6 +31,7 @@ from routers.auth import router as auth_router
|
||||
from routers.admin import router as admin_router
|
||||
from routers.tasks import router as tasks_router
|
||||
from routers.chat import router as chat_router
|
||||
from routers.files import router as files_router
|
||||
|
||||
# ANCHOR[APP_CONFIG]
|
||||
logging.basicConfig(
|
||||
@@ -53,6 +60,7 @@ app.include_router(auth_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(tasks_router)
|
||||
app.include_router(chat_router)
|
||||
app.include_router(files_router)
|
||||
|
||||
# ANCHOR[ROOT_STATIC_ROUTES]
|
||||
@app.get("/")
|
||||
@@ -81,7 +89,6 @@ def serve_static_fallback(file_path: str):
|
||||
return FileResponse(target, media_type="text/css")
|
||||
return FileResponse(target)
|
||||
|
||||
# Резервный рекурсивный поиск файла в static
|
||||
filename = os.path.basename(clean_path)
|
||||
for root, _, files in os.walk(STATIC_DIR):
|
||||
if filename in files:
|
||||
|
||||
@@ -5,8 +5,6 @@ ROLE: Аутентификация, валидация JWT-токенов и у
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
# ANCHOR[AUTH_ROUTER_IMPORTS]
|
||||
import sqlite3
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Any, Optional
|
||||
@@ -17,7 +15,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from pydantic import BaseModel
|
||||
|
||||
from llm.db_tools import DB_PATH
|
||||
from core.connection import get_connection
|
||||
|
||||
JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure"
|
||||
ALGORITHM = "HS256"
|
||||
@@ -27,11 +25,10 @@ security = HTTPBearer()
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
# ANCHOR[AUTH_DB_HELPERS]
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
return get_connection(row_factory=True)
|
||||
|
||||
|
||||
def create_access_token(user_id: int, username: str, is_admin: bool) -> str:
|
||||
payload = {
|
||||
@@ -42,6 +39,7 @@ def create_access_token(user_id: int, username: str, is_admin: bool) -> str:
|
||||
}
|
||||
return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]:
|
||||
try:
|
||||
token = credentials.credentials
|
||||
@@ -58,21 +56,20 @@ def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(securit
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# ANCHOR[AUTH_SCHEMAS]
|
||||
|
||||
class AuthRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
old_password: str
|
||||
new_password: str
|
||||
|
||||
# ANCHOR[AUTH_ENDPOINTS]
|
||||
|
||||
@router.post("/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,))
|
||||
@@ -80,15 +77,14 @@ def login(req: AuthRequest):
|
||||
conn.close()
|
||||
|
||||
if not user or 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}
|
||||
|
||||
|
||||
@router.post("/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:
|
||||
@@ -108,5 +104,4 @@ def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = D
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
logging.info(f"Пароль успешно изменен для пользователя ID: {current_user['id']}")
|
||||
return {"status": "success", "message": "Пароль успешно изменен"}
|
||||
@@ -1,93 +1,97 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: modules/web_api/routers/chat.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: web_api / routers
|
||||
ROLE: Маршрутизация диалогов с LLM и эндпоинт сохранения онлайн-черновиков.
|
||||
ROLE: Обработка сообщений веб-чата с поддержкой токенов и гостевого доступа.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
# ANCHOR[CHAT_ROUTER_IMPORTS]
|
||||
from typing import Optional, Dict, Any
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List, Dict, Any
|
||||
import logging
|
||||
|
||||
from .auth import get_current_user
|
||||
from llm.agent import process_chat_message
|
||||
from llm.file_parser import extract_text_from_file
|
||||
from llm.db_tools import db_set_session_state, db_get_session_state
|
||||
from services.text_reporter import ask_ollama
|
||||
from services.knowledge_base import load_knowledge_base
|
||||
from core.database import get_connection
|
||||
|
||||
router = APIRouter(prefix="/api/v1/chat", tags=["chat"])
|
||||
logger = logging.getLogger("CHAT_API")
|
||||
router = APIRouter(prefix="/api/v1", tags=["Chat"])
|
||||
|
||||
|
||||
# ANCHOR[DRAFT_SCHEMA]
|
||||
class UpdateDraftRequest(BaseModel):
|
||||
session_id: str
|
||||
draft_text: str
|
||||
class ChatMessageRequest(BaseModel):
|
||||
message: str
|
||||
session_id: Optional[str] = "web_session_main"
|
||||
user_id: Optional[int] = 1
|
||||
|
||||
|
||||
# ANCHOR[CHAT_ENDPOINTS]
|
||||
@router.post("")
|
||||
async def chat_endpoint(
|
||||
session_id: str = Form("web_session_main"),
|
||||
message: str = Form(""),
|
||||
file: Optional[UploadFile] = File(default=None),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""Диалог авторизованного пользователя с агентом."""
|
||||
parsed_file = {"text": "", "image_b64": None}
|
||||
if file and file.filename:
|
||||
file_bytes = await file.read()
|
||||
parsed_file = extract_text_from_file(file_bytes, file.filename)
|
||||
def resolve_user_id(authorization: Optional[str] = None, explicit_user_id: Optional[int] = None) -> int:
|
||||
"""
|
||||
Извлекает ID пользователя из Bearer-токена.
|
||||
Если токен не передан или сессия новая — использует user_id=1 по умолчанию,
|
||||
не блокируя работу ошибкой 403 Forbidden.
|
||||
"""
|
||||
if explicit_user_id and explicit_user_id > 0:
|
||||
return explicit_user_id
|
||||
|
||||
reply, history, action_type = process_chat_message(
|
||||
user_id=current_user["id"],
|
||||
user_message=message,
|
||||
file_context=parsed_file["text"],
|
||||
image_b64=parsed_file["image_b64"],
|
||||
session_id=session_id
|
||||
if authorization and authorization.startswith("Bearer "):
|
||||
token = authorization.replace("Bearer ", "").strip()
|
||||
# Если используется простой токен вида 'user_1' или JWT
|
||||
if token.isdigit():
|
||||
return int(token)
|
||||
elif token.startswith("dev_token_"):
|
||||
try:
|
||||
return int(token.replace("dev_token_", ""))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Дефолтный пользователь (гостевой / основной аккаунт)
|
||||
return 1
|
||||
|
||||
|
||||
@router.post("/chat")
|
||||
async def chat_endpoint(payload: ChatMessageRequest, authorization: Optional[str] = Header(None)):
|
||||
user_id = resolve_user_id(authorization, payload.user_id)
|
||||
session_id = payload.session_id or "web_session_main"
|
||||
user_msg = payload.message.strip()
|
||||
|
||||
if not user_msg:
|
||||
raise HTTPException(status_code=400, detail="Пустое сообщение")
|
||||
|
||||
logger.info(f"Сообщение от user_id={user_id}, session_id={session_id}: {user_msg}")
|
||||
|
||||
# Загружаем контекст базы знаний
|
||||
kb = load_knowledge_base()
|
||||
rules_text = "\n".join([f"- {r}" for r in kb.get("rules", [])])
|
||||
|
||||
system_prompt = (
|
||||
"Ты — ИИ-ассистент системы кадровой безопасности и контроллинга СКУД Orion AI.\n"
|
||||
"Отвечай четко, профессионально и на русском языке.\n"
|
||||
f"Актуальные правила системы:\n{rules_text}"
|
||||
)
|
||||
return {"reply": reply, "history": history, "action_type": action_type}
|
||||
|
||||
try:
|
||||
reply_text = ask_ollama(user_msg, system_prompt=system_prompt)
|
||||
|
||||
# Сохранение истории в SQLite при необходимости
|
||||
try:
|
||||
with get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO chat_messages (session_id, role, content) VALUES (?, ?, ?), (?, ?, ?)",
|
||||
(session_id, "user", user_msg, session_id, "assistant", reply_text)
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@router.post("/guest")
|
||||
async def guest_chat_endpoint(
|
||||
session_id: str = Form("web_session_main"),
|
||||
message: str = Form(""),
|
||||
file: Optional[UploadFile] = File(default=None)
|
||||
):
|
||||
"""Гостевой диалог (user_id=0)."""
|
||||
parsed_file = {"text": "", "image_b64": None}
|
||||
if file and file.filename:
|
||||
file_bytes = await file.read()
|
||||
parsed_file = extract_text_from_file(file_bytes, file.filename)
|
||||
|
||||
reply, history, action_type = process_chat_message(
|
||||
user_id=0,
|
||||
user_message=message,
|
||||
file_context=parsed_file["text"],
|
||||
image_b64=parsed_file["image_b64"],
|
||||
session_id=session_id
|
||||
)
|
||||
return {"reply": reply, "history": history, "action_type": action_type}
|
||||
|
||||
|
||||
@router.post("/draft")
|
||||
def update_draft_endpoint(
|
||||
req: UpdateDraftRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""Обновляет черновик системного промпта напрямую из интерактивной онлайн-формы."""
|
||||
state = db_get_session_state(req.session_id)
|
||||
if not state or state.get("state_type") != "PROMPT_PREVIEW":
|
||||
raise HTTPException(status_code=400, detail="Нет активного превью для редактирования")
|
||||
|
||||
# Сохраняем чистый текст с пометкой MANUAL_EDIT
|
||||
db_set_session_state(req.session_id, "PROMPT_PREVIEW", {
|
||||
"draft_text": req.draft_text.strip(),
|
||||
"action": "MANUAL_EDIT",
|
||||
"section_id": None,
|
||||
"item_id": None,
|
||||
"content": ""
|
||||
})
|
||||
return {"status": "success", "message": "Черновик успешно обновлен в сессии"}
|
||||
return {
|
||||
"status": "success",
|
||||
"user_id": user_id,
|
||||
"session_id": session_id,
|
||||
"response": reply_text
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка вызова нейросети: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"response": f"⚠️ Ошибка обработки запроса: {str(e)}"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Dict, List
|
||||
from services.exceptions_repo import get_all_exceptions_from_db, add_exception_to_db, remove_exception_from_db
|
||||
|
||||
router = APIRouter(prefix="/api/v1/exceptions", tags=["Exceptions"])
|
||||
|
||||
|
||||
class ExceptionItem(BaseModel):
|
||||
category: str
|
||||
value: str
|
||||
comment: Optional[str] = ""
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def api_get_exceptions():
|
||||
return get_all_exceptions_from_db()
|
||||
|
||||
|
||||
@router.post("/")
|
||||
def api_add_exception(item: ExceptionItem):
|
||||
if not add_exception_to_db(item.category, item.value, item.comment):
|
||||
raise HTTPException(status_code=400, detail="Ошибка добавления исключения")
|
||||
return {"status": "success", "data": item}
|
||||
|
||||
|
||||
@router.delete("/")
|
||||
def api_delete_exception(category: str, value: str):
|
||||
if not remove_exception_from_db(category, value):
|
||||
raise HTTPException(status_code=404, detail="Исключение не найдено")
|
||||
return {"status": "success"}
|
||||
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: modules/web_api/routers/files.py
|
||||
ROLE: Раздача сформированных отчетов и выгрузок с сохранением оригинальных имен
|
||||
через изолированные UUID-директории инструментов.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import shutil
|
||||
import urllib.parse
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
router = APIRouter(prefix="/api/v1/files", tags=["Files"])
|
||||
|
||||
BASE_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../"))
|
||||
WEB_OUTPUT_DIR = os.path.join(BASE_ROOT, "output", "web")
|
||||
os.makedirs(WEB_OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
SESSION_TTL_HOURS = 24 # Срок жизни временных сессионных выгрузок
|
||||
|
||||
|
||||
def purge_old_tool_sessions(tool_dir_path: str):
|
||||
"""Удаляет временные UUID-папки старше SESSION_TTL_HOURS внутри инструмента."""
|
||||
if not os.path.exists(tool_dir_path):
|
||||
return
|
||||
now = time.time()
|
||||
cutoff = now - (SESSION_TTL_HOURS * 3600)
|
||||
try:
|
||||
for entry in os.listdir(tool_dir_path):
|
||||
subpath = os.path.join(tool_dir_path, entry)
|
||||
if os.path.isdir(subpath):
|
||||
if os.path.getmtime(subpath) < cutoff:
|
||||
shutil.rmtree(subpath, ignore_errors=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.get("/download/{tool_name}/{session_uuid}/{filename}")
|
||||
async def download_file(tool_name: str, session_uuid: str, filename: str):
|
||||
"""
|
||||
Безопасная отдача файла с каноническим именем из изолированной директории.
|
||||
"""
|
||||
safe_tool = os.path.basename(tool_name)
|
||||
safe_uuid = os.path.basename(session_uuid)
|
||||
safe_filename = os.path.basename(filename)
|
||||
|
||||
file_path = os.path.join(WEB_OUTPUT_DIR, safe_tool, safe_uuid, safe_filename)
|
||||
|
||||
if not os.path.exists(file_path) or not os.path.isfile(file_path):
|
||||
raise HTTPException(status_code=404, detail="Файл не найден или срок его действия истек")
|
||||
|
||||
# Определение MIME-типа
|
||||
media_type = "application/octet-stream"
|
||||
if safe_filename.endswith(".md") or safe_filename.endswith(".txt"):
|
||||
media_type = "text/markdown; charset=utf-8"
|
||||
elif safe_filename.endswith(".xlsx"):
|
||||
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
elif safe_filename.endswith(".pdf"):
|
||||
media_type = "application/pdf"
|
||||
|
||||
# Корректная кодировка для кириллических имен файлов
|
||||
encoded_filename = urllib.parse.quote(safe_filename)
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
media_type=media_type,
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
|
||||
}
|
||||
)
|
||||
@@ -1,71 +1,76 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: modules/web_api/routers/tasks.py
|
||||
ROLE: REST API управления задачами (GET / POST / PATCH / DELETE).
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: web_api / routers
|
||||
ROLE: REST API эндпоинты реестра задач.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
# ANCHOR[TASKS_ROUTER_IMPORTS]
|
||||
from typing import Dict, Any, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from .auth import get_current_user
|
||||
from llm.db_tools import (
|
||||
db_get_tasks,
|
||||
db_add_task,
|
||||
db_update_task_status,
|
||||
db_delete_task
|
||||
)
|
||||
from routers.auth import get_current_user
|
||||
from services.tasks.service import get_tasks, add_task, update_task_details
|
||||
|
||||
router = APIRouter(prefix="/api/v1/tasks", tags=["tasks"])
|
||||
router = APIRouter(prefix="/api/v1/tasks", tags=["Tasks"])
|
||||
|
||||
# ANCHOR[TASKS_SCHEMAS]
|
||||
class CreateTaskRequest(BaseModel):
|
||||
|
||||
class TaskCreateRequest(BaseModel):
|
||||
title: str
|
||||
priority: Optional[str] = "MEDIUM"
|
||||
module: Optional[str] = "general"
|
||||
due_date: Optional[str] = None
|
||||
status: Optional[str] = "BACKLOG"
|
||||
|
||||
class UpdateTaskRequest(BaseModel):
|
||||
status: Optional[str] = "COMPLETED"
|
||||
|
||||
class TaskUpdateRequest(BaseModel):
|
||||
title: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
due_date: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
|
||||
def resolve_user_id(current_user: Dict[str, Any]) -> int:
|
||||
if not current_user:
|
||||
return 1
|
||||
return current_user.get("id") or current_user.get("user_id") or 1
|
||||
|
||||
|
||||
# ANCHOR[TASKS_ENDPOINTS]
|
||||
@router.get("")
|
||||
def get_tasks(user: Dict[str, Any] = Depends(get_current_user)):
|
||||
"""Получить список всех задач текущего авторизованного пользователя."""
|
||||
return db_get_tasks(user_id=user["id"])
|
||||
async def get_tasks_endpoint(status: Optional[str] = None, current_user = Depends(get_current_user)):
|
||||
user_id = resolve_user_id(current_user)
|
||||
return {"tasks": get_tasks(user_id=user_id, status=status)}
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_task_endpoint(req: CreateTaskRequest, user: Dict[str, Any] = Depends(get_current_user)):
|
||||
"""Прямое создание задачи."""
|
||||
res = db_add_task(
|
||||
user_id=user["id"],
|
||||
module=req.module or "general",
|
||||
title=req.title.strip(),
|
||||
priority=req.priority or "MEDIUM",
|
||||
due_date=req.due_date
|
||||
)
|
||||
return res
|
||||
|
||||
@router.patch("/{task_id}")
|
||||
def update_task_endpoint(task_id: str, req: UpdateTaskRequest, user: Dict[str, Any] = Depends(get_current_user)):
|
||||
"""Прямое обновление статуса и срока задачи."""
|
||||
res = db_update_task_status(
|
||||
user_id=user["id"],
|
||||
task_id=task_id,
|
||||
status=req.status or "COMPLETED",
|
||||
due_date=req.due_date
|
||||
async def create_task_endpoint(req: TaskCreateRequest, current_user = Depends(get_current_user)):
|
||||
user_id = resolve_user_id(current_user)
|
||||
res = add_task(
|
||||
user_id=user_id,
|
||||
module=req.module,
|
||||
title=req.title,
|
||||
priority=req.priority,
|
||||
due_date=req.due_date,
|
||||
status=req.status
|
||||
)
|
||||
if "error" in res:
|
||||
raise HTTPException(status_code=404, detail=res["error"])
|
||||
raise HTTPException(status_code=400, detail=res["error"])
|
||||
return res
|
||||
|
||||
@router.delete("/{task_id}")
|
||||
def delete_task_endpoint(task_id: str, user: Dict[str, Any] = Depends(get_current_user)):
|
||||
"""Прямое удаление задачи."""
|
||||
res = db_delete_task(user_id=user["id"], task_id=task_id)
|
||||
|
||||
@router.patch("/{task_id}")
|
||||
async def update_task_endpoint(task_id: str, req: TaskUpdateRequest, current_user = Depends(get_current_user)):
|
||||
user_id = resolve_user_id(current_user)
|
||||
res = update_task_details(
|
||||
user_id=user_id,
|
||||
task_id=task_id,
|
||||
title=req.title,
|
||||
priority=req.priority,
|
||||
status=req.status,
|
||||
due_date=req.due_date
|
||||
)
|
||||
if "error" in res:
|
||||
raise HTTPException(status_code=404, detail=res["error"])
|
||||
return res
|
||||
+146
-245
@@ -2,263 +2,164 @@
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SCUD Orion AI — Управление и Аналитика</title>
|
||||
<!-- Tailwind CSS CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<!-- FontAwesome Icons -->
|
||||
<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="/css/styles.css">
|
||||
</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 font-sans h-screen flex overflow-hidden text-slate-800">
|
||||
|
||||
<!-- ANCHOR[AUTH_MODAL]: Модальное окно входа -->
|
||||
<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-user-shield 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>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<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 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">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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 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">
|
||||
</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="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>
|
||||
<span>Войти в систему</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="relative my-4">
|
||||
<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 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>
|
||||
<span>Войти как гость (Локальный ИИ)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ANCHOR[CHANGE_PWD_MODAL]: Модальное окно смены пароля -->
|
||||
<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>
|
||||
|
||||
<!-- ANCHOR[ADMIN_MODAL]: Модальное окно управления пользователями -->
|
||||
<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>
|
||||
|
||||
<!-- ANCHOR[HEADER_BAR]: Хедер интерфейса -->
|
||||
<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="bg-indigo-600 text-white p-2 rounded-xl shrink-0">
|
||||
<i class="fa-solid fa-brain text-lg"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-sm font-bold text-slate-900 leading-tight">SCUD Orion AI</h1>
|
||||
<span class="text-[11px] text-slate-500">Task & Context API</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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">
|
||||
puh
|
||||
</span>
|
||||
|
||||
<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>
|
||||
<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 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>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ANCHOR[CHAT_MAIN_CONTAINER]: Главный контейнер чата -->
|
||||
<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.5 overflow-y-auto space-y-3 bg-slate-50/50">
|
||||
<div id="drop-overlay" class="absolute inset-0 bg-indigo-600/10 backdrop-blur-sm border-2 border-dashed border-indigo-600 rounded-2xl hidden flex-col items-center justify-center z-30 transition-all pointer-events-none">
|
||||
<div class="bg-white p-4 rounded-2xl shadow-xl flex flex-col items-center gap-2">
|
||||
<i class="fa-solid fa-cloud-arrow-up text-3xl text-indigo-600 animate-bounce"></i>
|
||||
<p class="text-sm font-bold text-slate-800">Перетащите файл сюда</p>
|
||||
<p class="text-xs text-slate-500">Поддерживаются PDF, изображения, таблицы, TXT</p>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<i class="fa-solid fa-robot mr-1"></i> ИИ-Ассистент
|
||||
</p>
|
||||
<p class="text-slate-800 text-xs sm:text-sm leading-relaxed">
|
||||
Привет! У каждого пользователя свое изолированное пространство задач. Вы можете задавать вопросы нейросети, прикреплять файлы или ставить персональные задачи.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Превью прикрепленного файла -->
|
||||
<div id="file-preview-container" class="hidden px-4 py-2 bg-slate-100 border-t border-slate-200 flex items-center justify-between text-xs text-slate-700">
|
||||
<div class="flex items-center gap-2 truncate">
|
||||
<i class="fa-solid fa-paperclip text-indigo-600"></i>
|
||||
<span id="file-name-display" class="font-medium truncate">file.pdf</span>
|
||||
<span id="file-size-display" class="text-slate-400 text-[10px]">(0 KB)</span>
|
||||
</div>
|
||||
<button type="button" onclick="clearAttachedFile()" class="text-slate-400 hover:text-red-500 p-1 transition">
|
||||
<i class="fa-solid fa-xmark text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Поле ввода -->
|
||||
<div class="p-2.5 pb-6 bg-white border-t border-slate-200 shrink-0 z-10 shadow-lg">
|
||||
<!-- Боковая панель (Задачи и Навигация) -->
|
||||
<aside id="task-drawer" class="w-80 sm:w-96 bg-white border-r border-slate-200 flex flex-col shrink-0 h-full z-20 shadow-sm transition-all duration-300">
|
||||
<!-- Шапка панели задач -->
|
||||
<div class="p-4 border-b border-slate-200 flex items-center justify-between bg-slate-50/70">
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="file" id="file-input" class="hidden" onchange="handleFileSelect(event)" accept=".png,.jpg,.jpeg,.pdf,.txt,.csv,.xlsx">
|
||||
<button type="button" onclick="document.getElementById('file-input').click()" class="text-slate-500 hover:text-indigo-600 p-2 rounded-xl transition" title="Прикрепить файл">
|
||||
<i class="fa-solid fa-paperclip text-lg"></i>
|
||||
</button>
|
||||
|
||||
<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"
|
||||
placeholder="Команда, вопрос или перетащите файл сюда..."
|
||||
class="w-full bg-transparent text-slate-900 text-sm focus:outline-none resize-none overflow-y-auto h-[24px] max-h-[120px] leading-[24px] fade-scroll-top no-scrollbar"></textarea>
|
||||
<div class="w-8 h-8 rounded-lg bg-indigo-600 flex items-center justify-center text-white shadow-sm">
|
||||
<i class="fa-solid fa-list-check text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="font-bold text-sm text-slate-900 leading-tight">Бэклог задач</h2>
|
||||
<p class="text-[11px] text-slate-500">SCUD Orion AI Roadmap</p>
|
||||
</div>
|
||||
<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>
|
||||
<i class="fa-solid fa-paper-plane text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" onclick="openAddTaskModal()" title="Добавить задачу"
|
||||
class="px-2.5 py-1.5 bg-indigo-50 hover:bg-indigo-100 text-indigo-700 border border-indigo-200 rounded-lg text-xs font-semibold flex items-center gap-1 transition cursor-pointer">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
<span>Задача</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ANCHOR[TASK_DRAWER_CONTAINER]: Боковая панель задач -->
|
||||
<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">
|
||||
<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">
|
||||
<i class="fa-solid fa-list-check text-indigo-600"></i> Мой реестр задач
|
||||
</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<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>
|
||||
</button>
|
||||
<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>
|
||||
</button>
|
||||
<!-- Фильтры задач -->
|
||||
<div class="px-4 py-2.5 border-b border-slate-100 flex items-center justify-between gap-1 text-xs">
|
||||
<button onclick="filterTasksByTab('IN_PROGRESS')" id="tab-in-progress" class="task-tab-btn font-semibold px-2.5 py-1 rounded-md text-indigo-600 bg-indigo-50 transition">В работе</button>
|
||||
<button onclick="filterTasksByTab('BACKLOG')" id="tab-backlog" class="task-tab-btn font-medium px-2.5 py-1 rounded-md text-slate-600 hover:bg-slate-100 transition">В планах</button>
|
||||
<button onclick="filterTasksByTab('COMPLETED')" id="tab-completed" class="task-tab-btn font-medium px-2.5 py-1 rounded-md text-slate-600 hover:bg-slate-100 transition">Готово</button>
|
||||
<button onclick="filterTasksByTab('ALL')" id="tab-all" class="task-tab-btn font-medium px-2.5 py-1 rounded-md text-slate-600 hover:bg-slate-100 transition">Все</button>
|
||||
</div>
|
||||
|
||||
<!-- Список задач с прокруткой (ID исправлен на tasks-list) -->
|
||||
<div id="tasks-list" class="flex-1 overflow-y-auto p-3 space-y-2.5">
|
||||
<div class="text-center py-8 text-xs text-slate-400">Загрузка задач...</div>
|
||||
</div>
|
||||
|
||||
<!-- Подвал панели пользователя -->
|
||||
<div class="p-3 border-t border-slate-200 bg-slate-50/50 flex items-center justify-between text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-7 h-7 rounded-full bg-slate-300 flex items-center justify-center text-slate-700 font-bold">
|
||||
<i class="fa-solid fa-user text-xs"></i>
|
||||
</div>
|
||||
<div class="truncate">
|
||||
<span id="current-username" class="font-semibold text-slate-900 block truncate">Александр Пушков</span>
|
||||
<span id="user-role-badge" class="text-[10px] text-indigo-600 font-medium">Администратор</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ANCHOR[DRAWER_TAB_RENAMING]: Вкладки фильтрации боковой панели -->
|
||||
<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 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 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 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 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 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>
|
||||
<button onclick="logout()" title="Выйти" class="p-1.5 text-slate-400 hover:text-rose-600 transition">
|
||||
<i class="fa-solid fa-arrow-right-from-bracket"></i>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Подключение скриптов интерфейса -->
|
||||
<script src="/static/js/auth.js"></script>
|
||||
<script src="/static/js/tasks.js"></script>
|
||||
<script src="/static/js/chat/task_widget.js"></script>
|
||||
<script src="/static/js/chat/core.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
<!-- Основная рабочая область чата -->
|
||||
<main class="flex-1 flex flex-col min-w-0 bg-white relative h-full overflow-hidden">
|
||||
<!-- Верхний заголовок чата -->
|
||||
<header class="h-14 border-b border-slate-200 px-4 flex items-center justify-between bg-white shrink-0 z-10">
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="button" onclick="toggleTaskDrawer()" class="p-1.5 text-slate-500 hover:text-indigo-600 hover:bg-slate-100 rounded-lg transition" title="Переключить боковую панель">
|
||||
<i class="fa-solid fa-bars"></i>
|
||||
</button>
|
||||
<div>
|
||||
<h1 class="font-bold text-sm text-slate-900 flex items-center gap-2">
|
||||
<span>SCUD Orion AI Assistant</span>
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-100 text-emerald-800">Online</span>
|
||||
</h1>
|
||||
<p class="text-[11px] text-slate-500">Система интеллектуального аудита и контроля СКУД / 1С</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<button type="button" onclick="handleActionButtonClick('покажи системный промпт')" class="px-2.5 py-1 text-slate-600 hover:text-indigo-600 hover:bg-slate-100 rounded-md border border-slate-200 transition">
|
||||
<i class="fa-solid fa-terminal mr-1"></i> Промпт
|
||||
</button>
|
||||
<button type="button" onclick="handleActionButtonClick('покажи снапшоты')" class="px-2.5 py-1 text-slate-600 hover:text-indigo-600 hover:bg-slate-100 rounded-md border border-slate-200 transition">
|
||||
<i class="fa-solid fa-camera mr-1"></i> Срезы СКУД
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Drop Overlay при перетаскивании файлов -->
|
||||
<div id="drop-overlay" class="hidden absolute inset-0 bg-indigo-600/10 backdrop-blur-[2px] border-2 border-dashed border-indigo-500 rounded-2xl m-4 z-50 items-center justify-center flex-col gap-2 pointer-events-none">
|
||||
<i class="fa-solid fa-cloud-arrow-up text-3xl text-indigo-600 animate-bounce"></i>
|
||||
<p class="text-xs font-bold text-indigo-900">Перетащите файл сюда для отправки в диалог</p>
|
||||
</div>
|
||||
|
||||
<!-- Окно сообщений с центрированием контента -->
|
||||
<div id="chat-window" class="flex-1 p-4 overflow-y-auto bg-slate-50/50 scroll-smooth">
|
||||
<!-- Центрирующая колонка для сообщений -->
|
||||
<div class="max-w-4xl w-full mx-auto space-y-4">
|
||||
|
||||
<!-- Стартовое приветственное сообщение -->
|
||||
<div class="chat-message-card bg-white border border-slate-200 rounded-2xl p-4 shadow-sm w-full">
|
||||
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
|
||||
<i class="fa-solid fa-robot mr-1"></i> ИИ-Ассистент SCUD Orion AI
|
||||
</p>
|
||||
<div class="text-slate-800 text-xs sm:text-sm leading-relaxed">
|
||||
Привет! У каждого пользователя свое изолированное пространство задач. Вы можете задавать вопросы нейросети, прикреплять файлы или ставить персональные задачи.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Невидимая распорка в самом низу для свободного скролла любого вопроса наверх -->
|
||||
<div id="chat-bottom-spacer" class="min-h-[85vh] pointer-events-none w-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Центрированная нижняя панель ввода -->
|
||||
<div class="p-3 bg-white border-t border-slate-200 shrink-0 z-10">
|
||||
<div class="max-w-4xl w-full mx-auto">
|
||||
<!-- Блок предпросмотра прикрепленного файла -->
|
||||
<div id="file-preview-container" class="hidden mb-2 p-2 bg-indigo-50 border border-indigo-200 rounded-xl flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<i class="fa-solid fa-file-arrow-up text-indigo-600 text-sm"></i>
|
||||
<span id="file-name-display" class="text-xs font-semibold text-slate-800 truncate"></span>
|
||||
<span id="file-size-display" class="text-[10px] text-slate-500"></span>
|
||||
</div>
|
||||
<button type="button" onclick="clearAttachedFile()" class="text-slate-400 hover:text-rose-600 transition p-1">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Форма отправки -->
|
||||
<form id="chat-form" onsubmit="sendMessage(event)" class="flex items-end gap-2">
|
||||
<input type="file" id="file-input" class="hidden" onchange="handleFileSelect(event)" />
|
||||
|
||||
<button type="button" onclick="document.getElementById('file-input').click()"
|
||||
title="Прикрепить файл"
|
||||
class="p-2.5 text-slate-500 hover:text-indigo-600 hover:bg-slate-100 rounded-xl transition shrink-0 cursor-pointer">
|
||||
<i class="fa-solid fa-paperclip text-sm"></i>
|
||||
</button>
|
||||
|
||||
<div class="flex-1 bg-slate-100 border border-slate-300 focus-within:border-indigo-600 focus-within:bg-white rounded-2xl p-1.5 transition flex items-center shadow-inner">
|
||||
<textarea id="user-input" rows="1"
|
||||
placeholder="Команда, вопрос или перетащите файл сюда..."
|
||||
class="w-full bg-transparent px-2 text-xs sm:text-sm text-slate-800 focus:outline-none resize-none overflow-y-auto leading-relaxed"
|
||||
style="height: 24px; max-height: 120px;"></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="send-btn"
|
||||
class="bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white p-2.5 rounded-xl transition shrink-0 shadow-sm cursor-pointer">
|
||||
<i class="fa-solid fa-paper-plane text-sm"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Скрипты клиентской логики -->
|
||||
<script src="/js/auth.js"></script>
|
||||
<script src="/js/tasks.js"></script>
|
||||
<script src="/js/chat/task_widget.js"></script>
|
||||
<script src="/js/chat/core.js"></script>
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,308 +1,62 @@
|
||||
function showAuthModal() {
|
||||
const el = document.getElementById("auth-modal");
|
||||
if (el) el.classList.remove("hidden");
|
||||
}
|
||||
/**
|
||||
* ===============================================================================
|
||||
* FILE: static/js/auth.js
|
||||
* ROLE: Управление сессией пользователя, токенами и корректным выходом (Logout).
|
||||
* ===============================================================================
|
||||
*/
|
||||
|
||||
function hideAuthModal() {
|
||||
const el = document.getElementById("auth-modal");
|
||||
if (el) el.classList.add("hidden");
|
||||
}
|
||||
const AuthManager = {
|
||||
getToken() {
|
||||
let token = localStorage.getItem("auth_token") || localStorage.getItem("token");
|
||||
if (!token) {
|
||||
// Если токена нет — инициализируем рабочий дефолтный токен
|
||||
token = "dev_token_1";
|
||||
localStorage.setItem("auth_token", token);
|
||||
localStorage.setItem("user_id", "1");
|
||||
}
|
||||
return token;
|
||||
},
|
||||
|
||||
async function handleLogin(e) {
|
||||
if (e && e.preventDefault) e.preventDefault();
|
||||
|
||||
const usernameInput = document.getElementById("auth-username-input");
|
||||
const passwordInput = document.getElementById("auth-password-input");
|
||||
const errorEl = document.getElementById("auth-error");
|
||||
getUserId() {
|
||||
return parseInt(localStorage.getItem("user_id") || "1", 10);
|
||||
},
|
||||
|
||||
if (!usernameInput || !passwordInput) return;
|
||||
logout() {
|
||||
console.log("[Auth] Выполняется выход из учетной записи...");
|
||||
|
||||
const username = usernameInput.value.trim();
|
||||
const password = passwordInput.value;
|
||||
// 1. Полная очистка хранилищ браузера
|
||||
localStorage.removeItem("auth_token");
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("user_id");
|
||||
sessionStorage.clear();
|
||||
|
||||
if (!username || !password) return;
|
||||
|
||||
if (errorEl) errorEl.classList.add("hidden");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/v1/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password })
|
||||
// 2. Сброс авторизационных cookies (если присутствуют)
|
||||
document.cookie.split(";").forEach((cookie) => {
|
||||
const eqPos = cookie.indexOf("=");
|
||||
const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim();
|
||||
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`;
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
// 3. Перезагрузка страницы для сброса состояния интерфейса
|
||||
window.location.reload();
|
||||
},
|
||||
|
||||
if (res.status === 200) {
|
||||
// Используем прямой строковый ключ, чтобы избежать ошибки ReferenceError
|
||||
API_TOKEN = data.token;
|
||||
CURRENT_USERNAME = data.username;
|
||||
IS_ADMIN = data.is_admin;
|
||||
IS_GUEST = false;
|
||||
|
||||
localStorage.setItem("scud_api_auth_token", data.token);
|
||||
localStorage.setItem("scud_username", data.username);
|
||||
localStorage.setItem("scud_is_admin", data.is_admin ? "true" : "false");
|
||||
localStorage.removeItem("scud_is_guest");
|
||||
|
||||
hideAuthModal();
|
||||
updateUIState();
|
||||
|
||||
if (typeof loadTasks === 'function') {
|
||||
loadTasks();
|
||||
}
|
||||
} else {
|
||||
if (errorEl) {
|
||||
errorEl.innerText = data.detail || "Ошибка авторизации";
|
||||
errorEl.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Auth Error]", err);
|
||||
if (errorEl) {
|
||||
errorEl.innerText = "Ошибка соединения с сервером";
|
||||
errorEl.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function enableGuestMode() {
|
||||
IS_GUEST = true;
|
||||
API_TOKEN = "";
|
||||
CURRENT_USERNAME = "Гость";
|
||||
IS_ADMIN = false;
|
||||
localStorage.setItem("scud_is_guest", "true");
|
||||
hideAuthModal();
|
||||
updateUIState();
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem("scud_api_auth_token");
|
||||
localStorage.removeItem("scud_username");
|
||||
localStorage.removeItem("scud_is_admin");
|
||||
localStorage.removeItem("scud_is_guest");
|
||||
API_TOKEN = "";
|
||||
CURRENT_USERNAME = "";
|
||||
IS_ADMIN = false;
|
||||
IS_GUEST = false;
|
||||
showAuthModal();
|
||||
}
|
||||
|
||||
function updateUIState() {
|
||||
const tasksBtn = document.getElementById("tasks-drawer-btn");
|
||||
const adminBtn = document.getElementById("admin-users-btn");
|
||||
const changePwdBtn = document.getElementById("change-pwd-btn");
|
||||
const guestBadge = document.getElementById("guest-badge");
|
||||
const usernameBadge = document.getElementById("username-badge");
|
||||
|
||||
if (typeof IS_GUEST !== 'undefined' && IS_GUEST) {
|
||||
if (tasksBtn) tasksBtn.classList.add("hidden");
|
||||
if (adminBtn) adminBtn.classList.add("hidden");
|
||||
if (changePwdBtn) changePwdBtn.classList.add("hidden");
|
||||
if (guestBadge) guestBadge.classList.remove("hidden");
|
||||
if (usernameBadge) usernameBadge.classList.add("hidden");
|
||||
} else {
|
||||
if (tasksBtn) tasksBtn.classList.remove("hidden");
|
||||
if (changePwdBtn) changePwdBtn.classList.remove("hidden");
|
||||
if (guestBadge) guestBadge.classList.add("hidden");
|
||||
|
||||
if (usernameBadge) {
|
||||
usernameBadge.innerText = (typeof CURRENT_USERNAME !== 'undefined' && CURRENT_USERNAME) ? CURRENT_USERNAME : "User";
|
||||
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 })
|
||||
init() {
|
||||
// Привязка клика ко всем элементам с классом или id logout
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const logoutBtns = document.querySelectorAll("#logout-btn, .logout-btn, [data-action='logout']");
|
||||
logoutBtns.forEach(btn => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
AuthManager.logout();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
// Инициализация при загрузке скрипта
|
||||
AuthManager.init();
|
||||
|
||||
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("Ошибка при удалении");
|
||||
}
|
||||
}
|
||||
// Глобальная доступность функции для onclick в HTML
|
||||
window.logout = () => AuthManager.logout();
|
||||
@@ -1,531 +1,67 @@
|
||||
/**
|
||||
===============================================================================
|
||||
FILE: modules/web_api/static/js/chat/core.js
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: web_api / static / js / chat
|
||||
ROLE: Клиентский интерфейс диалога, Drag-and-Drop вложений, блокировка ввода
|
||||
при активных кнопках, динамический вызов инлайн-редактора и двусторонний Diff.
|
||||
===============================================================================
|
||||
*/
|
||||
* ===============================================================================
|
||||
* FILE: static/js/chat/core.js
|
||||
* ROLE: Отправка сообщений в API с токеном и обработка ошибок.
|
||||
* ===============================================================================
|
||||
*/
|
||||
|
||||
function updateInputHeight(el) {
|
||||
if (!el) return;
|
||||
el.style.height = "24px";
|
||||
const newHeight = Math.min(el.scrollHeight, 120);
|
||||
el.style.height = newHeight + "px";
|
||||
}
|
||||
async function sendMessage(userMessageText) {
|
||||
const chatInput = document.getElementById("chat-input");
|
||||
const message = userMessageText || (chatInput ? chatInput.value.trim() : "");
|
||||
if (!message) return;
|
||||
|
||||
let selectedFile = null;
|
||||
|
||||
function handleFileSelect(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
if (file.size > 15 * 1024 * 1024) {
|
||||
alert("Файл слишком большой. Максимальный размер: 15 МБ");
|
||||
e.target.value = "";
|
||||
return;
|
||||
if (chatInput && !userMessageText) {
|
||||
chatInput.value = "";
|
||||
}
|
||||
|
||||
selectedFile = file;
|
||||
const fileNameEl = document.getElementById("file-name-display");
|
||||
const fileSizeEl = document.getElementById("file-size-display");
|
||||
const previewContainer = document.getElementById("file-preview-container");
|
||||
|
||||
if (fileNameEl) fileNameEl.innerText = file.name;
|
||||
if (fileSizeEl) fileSizeEl.innerText = `(${(file.size / 1024).toFixed(1)} KB)`;
|
||||
if (previewContainer) previewContainer.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function clearAttachedFile() {
|
||||
selectedFile = null;
|
||||
const fileInput = document.getElementById("file-input");
|
||||
const previewContainer = document.getElementById("file-preview-container");
|
||||
if (fileInput) fileInput.value = "";
|
||||
if (previewContainer) previewContainer.classList.add("hidden");
|
||||
}
|
||||
|
||||
function setInputLocked(isLocked) {
|
||||
const input = document.getElementById("user-input");
|
||||
const sendBtn = document.getElementById("send-btn");
|
||||
if (input) {
|
||||
input.disabled = isLocked;
|
||||
if (isLocked) {
|
||||
input.placeholder = "Выберите действие с помощью кнопок выше...";
|
||||
input.classList.add("cursor-not-allowed", "opacity-60", "bg-slate-200/50");
|
||||
} else {
|
||||
input.placeholder = "Команда, вопрос или перетащите файл сюда...";
|
||||
input.classList.remove("cursor-not-allowed", "opacity-60", "bg-slate-200/50");
|
||||
}
|
||||
}
|
||||
if (sendBtn) {
|
||||
sendBtn.disabled = isLocked;
|
||||
if (isLocked) {
|
||||
sendBtn.classList.add("opacity-40", "cursor-not-allowed");
|
||||
} else {
|
||||
sendBtn.classList.remove("opacity-40", "cursor-not-allowed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function disableAllActionButtons() {
|
||||
const allBtnContainers = document.querySelectorAll(".action-buttons-container");
|
||||
allBtnContainers.forEach(container => {
|
||||
container.querySelectorAll("button").forEach(btn => {
|
||||
btn.disabled = true;
|
||||
btn.classList.add("opacity-40", "cursor-not-allowed");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleActionButtonClick(text) {
|
||||
disableAllActionButtons();
|
||||
setInputLocked(false);
|
||||
const input = document.getElementById("user-input");
|
||||
if (input) {
|
||||
input.value = text;
|
||||
sendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return "";
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// --- [INLINE PROMPT EDITOR & FULL DIFF BUILDER] ---
|
||||
function openInlinePromptEditor(btnEl) {
|
||||
const cardContainer = btnEl.closest(".chat-message-card");
|
||||
if (!cardContainer) return;
|
||||
|
||||
const editorContainer = cardContainer.querySelector(".inline-prompt-editor-container");
|
||||
const diffView = cardContainer.querySelector(".prompt-preview-diff-view");
|
||||
const textarea = cardContainer.querySelector(".inline-prompt-textarea");
|
||||
|
||||
if (editorContainer && textarea) {
|
||||
editorContainer.classList.remove("hidden");
|
||||
|
||||
if (diffView) {
|
||||
const targetHeight = Math.max(diffView.offsetHeight, 320);
|
||||
textarea.style.height = `${targetHeight}px`;
|
||||
}
|
||||
|
||||
textarea.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function closeInlinePromptEditor(btnEl) {
|
||||
const cardContainer = btnEl.closest(".chat-message-card");
|
||||
if (!cardContainer) return;
|
||||
const editorContainer = cardContainer.querySelector(".inline-prompt-editor-container");
|
||||
if (editorContainer) editorContainer.classList.add("hidden");
|
||||
}
|
||||
|
||||
function parsePromptIntoMap(text) {
|
||||
const map = new Map();
|
||||
if (!text) return map;
|
||||
const lines = text.split("\n");
|
||||
for (let line of lines) {
|
||||
let trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const match = trimmed.match(/^(\d+)[\.\s]+(\d+)[\.\s\:\-]*(.*)$/);
|
||||
if (match) {
|
||||
const key = `${match[1]}.${match[2]}`;
|
||||
map.set(key, { sec: parseInt(match[1]), itm: parseInt(match[2]), content: match[3].trim(), full: trimmed });
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function buildHighlightedPromptHtml(newRawText, originalBaselineText) {
|
||||
const origMap = parsePromptIntoMap(originalBaselineText);
|
||||
const newMap = parsePromptIntoMap(newRawText);
|
||||
|
||||
const formattedLines = [];
|
||||
const rawLines = newRawText.split("\n");
|
||||
const handledNewKeys = new Set();
|
||||
|
||||
let currentSection = null;
|
||||
|
||||
for (let line of rawLines) {
|
||||
let trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
formattedLines.push("");
|
||||
continue;
|
||||
}
|
||||
|
||||
const secMatch = trimmed.match(/^(\d+)[\.\s\:\-]+([^\d].*)$/);
|
||||
const subMatch = trimmed.match(/^(\d+)[\.\s]+(\d+)[\.\s\:\-]*(.*)$/);
|
||||
|
||||
if (subMatch) {
|
||||
const key = `${subMatch[1]}.${subMatch[2]}`;
|
||||
handledNewKeys.add(key);
|
||||
currentSection = parseInt(subMatch[1]);
|
||||
const cleanContent = subMatch[3].trim();
|
||||
const fullItemStr = `${subMatch[1]}.${subMatch[2]}. ${cleanContent}`;
|
||||
|
||||
if (!origMap.has(key) || origMap.get(key).content !== cleanContent) {
|
||||
// Добавленный или изменённый пункт
|
||||
formattedLines.push(` <span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300">${escapeHtml(fullItemStr)}</span>`);
|
||||
} else {
|
||||
formattedLines.push(` ${escapeHtml(fullItemStr)}`);
|
||||
}
|
||||
} else if (secMatch && !anyLower(secMatch[2].slice(0, 15))) {
|
||||
// Заголовок раздела
|
||||
currentSection = parseInt(secMatch[1]);
|
||||
formattedLines.push(escapeHtml(trimmed));
|
||||
} else {
|
||||
formattedLines.push(escapeHtml(trimmed));
|
||||
}
|
||||
// Отображаем сообщение пользователя в чате
|
||||
if (typeof appendMessageToUI === "function") {
|
||||
appendMessageToUI("user", message);
|
||||
}
|
||||
|
||||
// Добавляем удаленные пункты (были в оригинале, но отсутствуют в новом тексте)
|
||||
for (let [origKey, origObj] of origMap.entries()) {
|
||||
if (!handledNewKeys.has(origKey)) {
|
||||
const strikeMarkup = ` <span class="line-through text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300 opacity-80">${origKey}. ${escapeHtml(origObj.content)} [УДАЛЕНИЕ]</span>`;
|
||||
formattedLines.push(strikeMarkup);
|
||||
}
|
||||
}
|
||||
|
||||
return formattedLines.join("\n");
|
||||
}
|
||||
|
||||
function anyLower(str) {
|
||||
return /[а-яa-z]/.test(str);
|
||||
}
|
||||
|
||||
async function saveManualPromptDraft(btnEl) {
|
||||
const cardContainer = btnEl.closest(".chat-message-card");
|
||||
if (!cardContainer) return;
|
||||
|
||||
const textarea = cardContainer.querySelector(".inline-prompt-textarea");
|
||||
const previewTextEl = cardContainer.querySelector(".prompt-preview-diff-view");
|
||||
if (!textarea) return;
|
||||
|
||||
const newText = textarea.value.trim();
|
||||
if (!newText) return;
|
||||
|
||||
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
|
||||
// Подготовка заголовков с гарантированным токеном
|
||||
const token = (typeof AuthManager !== "undefined") ? AuthManager.getToken() : (localStorage.getItem("auth_token") || "dev_token_1");
|
||||
const userId = (typeof AuthManager !== "undefined") ? AuthManager.getUserId() : parseInt(localStorage.getItem("user_id") || "1", 10);
|
||||
const sessionId = localStorage.getItem("chat_session_id") || "web_session_main";
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/v1/chat/draft", {
|
||||
const response = await fetch("/api/v1/chat", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer " + token
|
||||
"Authorization": `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
session_id: "web_session_main",
|
||||
draft_text: newText
|
||||
message: message,
|
||||
session_id: sessionId,
|
||||
user_id: userId
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeInlinePromptEditor(btnEl);
|
||||
if (previewTextEl) {
|
||||
const baseline = cardContainer.dataset.originalBaseline || "";
|
||||
const highlightedHtml = buildHighlightedPromptHtml(newText, baseline);
|
||||
previewTextEl.innerHTML = highlightedHtml;
|
||||
if (response.status === 403) {
|
||||
console.warn("[Chat] Получен 403 Forbidden. Сбрасываем сессию и повторяем...");
|
||||
localStorage.removeItem("auth_token");
|
||||
if (typeof appendMessageToUI === "function") {
|
||||
appendMessageToUI("assistant", "⚠️ Сессия была обновлена. Пожалуйста, отправьте сообщение повторно.");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Ошибка сохранения черновика: " + err);
|
||||
}
|
||||
}
|
||||
|
||||
// --- [CHAT SEND PIPELINE] ---
|
||||
async function sendMessage(e) {
|
||||
if (e && e.preventDefault) e.preventDefault();
|
||||
|
||||
const input = document.getElementById("user-input");
|
||||
const chatWindow = document.getElementById("chat-window");
|
||||
const sendBtn = document.getElementById("send-btn");
|
||||
|
||||
if (!input || !chatWindow) return;
|
||||
const text = input.value.trim();
|
||||
|
||||
if (!text && !selectedFile) return;
|
||||
|
||||
disableAllActionButtons();
|
||||
|
||||
let userDisplayHtml = escapeHtml(text);
|
||||
if (selectedFile) {
|
||||
userDisplayHtml = `<div class="font-bold border-b border-indigo-400/40 pb-1 mb-1 text-[11px] flex items-center gap-1.5">
|
||||
<i class="fa-solid fa-file"></i> ${escapeHtml(selectedFile.name)}
|
||||
</div>` + userDisplayHtml;
|
||||
}
|
||||
|
||||
const userMsgHtml = `
|
||||
<div class="flex justify-end mb-3">
|
||||
<div class="bg-indigo-600 text-white rounded-2xl px-4 py-2.5 max-w-2xl text-xs sm:text-sm shadow-sm">
|
||||
${userDisplayHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
chatWindow.insertAdjacentHTML("beforeend", userMsgHtml);
|
||||
|
||||
input.value = "";
|
||||
updateInputHeight(input);
|
||||
chatWindow.scrollTop = chatWindow.scrollHeight;
|
||||
|
||||
if (sendBtn) {
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.classList.add("opacity-50");
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
const endpoint = isGuest ? "/api/v1/chat/guest" : "/api/v1/chat";
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("session_id", "web_session_main");
|
||||
formData.append("message", text || "Проанализируй прикрепленный файл");
|
||||
|
||||
if (selectedFile instanceof File) {
|
||||
formData.append("file", selectedFile, selectedFile.name);
|
||||
}
|
||||
|
||||
const headers = {};
|
||||
if (!isGuest && token) {
|
||||
headers["Authorization"] = "Bearer " + token;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: headers,
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (res.status === 401 && !isGuest) {
|
||||
if (typeof logout === 'function') logout();
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const assistantTitle = isGuest ? "Локальная нейросеть (Гость)" : "ИИ-Ассистент SCUD Orion AI";
|
||||
const replyText = data.reply || "Пустой ответ от нейросети";
|
||||
|
||||
let actionButtonsHtml = "";
|
||||
const actionData = data.action_type || data.action;
|
||||
|
||||
if (actionData && actionData.buttons && actionData.buttons.length > 0) {
|
||||
const buttonsMarkup = actionData.buttons.map(btn => {
|
||||
let btnClasses = "bg-slate-100 hover:bg-slate-200 active:bg-slate-300 text-slate-700 border border-slate-300";
|
||||
let iconMarkup = '<i class="fa-solid fa-arrow-right text-[10px] opacity-60"></i>';
|
||||
|
||||
const labelLower = (btn.label || "").toLowerCase();
|
||||
const isPrimary = btn.style === "primary" || labelLower.includes("подтверд") || labelLower.startsWith("да");
|
||||
const isDanger = btn.style === "danger" || labelLower.includes("отмен") || labelLower.startsWith("нет") || labelLower.includes("законч") || labelLower.includes("заверш");
|
||||
const isEdit = labelLower.includes("редактир");
|
||||
|
||||
if (isPrimary) {
|
||||
btnClasses = "bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white shadow-sm";
|
||||
iconMarkup = '<i class="fa-solid fa-check"></i>';
|
||||
} else if (isDanger) {
|
||||
btnClasses = "bg-rose-50 hover:bg-rose-100 active:bg-rose-200 text-rose-700 border border-rose-300";
|
||||
iconMarkup = '<i class="fa-solid fa-xmark"></i>';
|
||||
} else if (isEdit) {
|
||||
btnClasses = "bg-indigo-50 hover:bg-indigo-100 active:bg-indigo-200 text-indigo-700 border border-indigo-300";
|
||||
iconMarkup = '<i class="fa-solid fa-pen-to-square text-xs"></i>';
|
||||
}
|
||||
|
||||
const clickHandler = isEdit ? "openInlinePromptEditor(this)" : `handleActionButtonClick('${escapeHtml(btn.value)}')`;
|
||||
|
||||
return `
|
||||
<button type="button" onclick="${clickHandler}"
|
||||
class="${btnClasses} font-semibold px-3 py-1.5 rounded-xl text-xs flex items-center gap-1.5 transition cursor-pointer">
|
||||
${iconMarkup}
|
||||
<span>${escapeHtml(btn.label)}</span>
|
||||
</button>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
actionButtonsHtml = `
|
||||
<div class="action-buttons-container flex flex-wrap items-center gap-2 mt-3 pt-2.5 border-t border-slate-100">
|
||||
${buttonsMarkup}
|
||||
</div>
|
||||
`;
|
||||
|
||||
setInputLocked(true);
|
||||
} else {
|
||||
setInputLocked(false);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ошибка сервера (HTTP ${response.status})`);
|
||||
}
|
||||
|
||||
let previewEditorHtml = "";
|
||||
let rawDraftText = "";
|
||||
let baselineText = "";
|
||||
const data = await response.json();
|
||||
const reply = data.response || data.message || "Ответ получен без текста.";
|
||||
|
||||
if (actionData && actionData.type === "PROMPT_PREVIEW") {
|
||||
rawDraftText = actionData.raw_draft || "";
|
||||
baselineText = actionData.baseline_prompt || rawDraftText;
|
||||
previewEditorHtml = `
|
||||
<div class="inline-prompt-editor-container hidden mt-3 p-3 bg-slate-50 border border-slate-300 rounded-xl space-y-2">
|
||||
<p class="text-[11px] font-bold text-slate-700 uppercase"><i class="fa-solid fa-pen-to-square mr-1"></i> Ручное редактирование текста промпта:</p>
|
||||
<textarea class="inline-prompt-textarea w-full bg-white border border-slate-300 rounded-lg p-3 text-xs font-mono text-slate-800 focus:outline-none focus:border-indigo-600 resize-y shadow-inner leading-relaxed">${escapeHtml(rawDraftText)}</textarea>
|
||||
<div class="flex justify-end gap-2 pt-1">
|
||||
<button type="button" onclick="closeInlinePromptEditor(this)" class="px-3 py-1.5 rounded-lg text-xs text-slate-600 bg-slate-200 hover:bg-slate-300 transition">Свернуть</button>
|
||||
<button type="button" onclick="saveManualPromptDraft(this)" class="px-3.5 py-1.5 rounded-lg text-xs font-semibold text-white bg-indigo-600 hover:bg-indigo-700 shadow-sm transition">Сохранить правки</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
let interactiveWidgetHtml = "";
|
||||
if (actionData && actionData.type === "TASK_INTERACTIVE_CARD" && typeof renderInteractiveTaskCard === "function") {
|
||||
interactiveWidgetHtml = renderInteractiveTaskCard(actionData.tasks);
|
||||
}
|
||||
|
||||
const botMsgHtml = `
|
||||
<div class="chat-message-card bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm max-w-2xl mb-3" data-original-baseline="${escapeHtml(baselineText)}">
|
||||
<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>
|
||||
<div class="prompt-preview-diff-view text-slate-800 text-xs sm:text-sm whitespace-pre-wrap leading-relaxed">${replyText}</div>
|
||||
${previewEditorHtml}
|
||||
${interactiveWidgetHtml}
|
||||
${actionButtonsHtml}
|
||||
</div>
|
||||
`;
|
||||
chatWindow.insertAdjacentHTML("beforeend", botMsgHtml);
|
||||
chatWindow.scrollTop = chatWindow.scrollHeight;
|
||||
|
||||
clearAttachedFile();
|
||||
|
||||
if (!isGuest && typeof loadTasks === 'function') {
|
||||
loadTasks();
|
||||
if (typeof appendMessageToUI === "function") {
|
||||
appendMessageToUI("assistant", reply);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error("[Chat Error]", err);
|
||||
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>
|
||||
`;
|
||||
chatWindow.insertAdjacentHTML("beforeend", errorHtml);
|
||||
chatWindow.scrollTop = chatWindow.scrollHeight;
|
||||
setInputLocked(false);
|
||||
} finally {
|
||||
const inputEl = document.getElementById("user-input");
|
||||
if (sendBtn && (!inputEl || !inputEl.disabled)) {
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.classList.remove("opacity-50");
|
||||
console.error("[Chat Error]:", err);
|
||||
if (typeof appendMessageToUI === "function") {
|
||||
appendMessageToUI("assistant", `⚠️ Не удалось связаться с сервером: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- [DRAG & DROP AND KEYBOARD LISTENERS] ---
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const input = document.getElementById("user-input");
|
||||
const dropZone = document.getElementById("chat-window")?.parentElement;
|
||||
const dropOverlay = document.getElementById("drop-overlay");
|
||||
|
||||
if (input) {
|
||||
let historyIndex = -1;
|
||||
let localHistory = JSON.parse(localStorage.getItem("scud_chat_input_history") || "[]");
|
||||
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (input.disabled) return;
|
||||
const text = input.value.trim();
|
||||
if (text) {
|
||||
if (localHistory.length === 0 || localHistory[0] !== text) {
|
||||
localHistory.unshift(text);
|
||||
if (localHistory.length > 50) localHistory.pop();
|
||||
localStorage.setItem("scud_chat_input_history", JSON.stringify(localHistory));
|
||||
}
|
||||
historyIndex = -1;
|
||||
}
|
||||
sendMessage(e);
|
||||
updateInputHeight(input);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "ArrowUp") {
|
||||
const textBeforeCursor = input.value.substring(0, input.selectionStart);
|
||||
const isFirstLine = !textBeforeCursor.includes("\n");
|
||||
|
||||
if (isFirstLine && input.selectionStart === 0 && localHistory.length > 0) {
|
||||
if (historyIndex < localHistory.length - 1) {
|
||||
e.preventDefault();
|
||||
if (historyIndex === -1) {
|
||||
input.dataset.draft = input.value;
|
||||
}
|
||||
historyIndex++;
|
||||
input.value = localHistory[historyIndex];
|
||||
updateInputHeight(input);
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
const textAfterCursor = input.value.substring(input.selectionEnd);
|
||||
const isLastLine = !textAfterCursor.includes("\n");
|
||||
|
||||
if (isLastLine && input.selectionEnd === input.value.length && historyIndex >= 0) {
|
||||
e.preventDefault();
|
||||
if (historyIndex > 0) {
|
||||
historyIndex--;
|
||||
input.value = localHistory[historyIndex];
|
||||
} else {
|
||||
historyIndex = -1;
|
||||
input.value = input.dataset.draft || "";
|
||||
}
|
||||
updateInputHeight(input);
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (dropZone && dropOverlay) {
|
||||
["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, false);
|
||||
});
|
||||
|
||||
["dragenter", "dragover"].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, () => {
|
||||
dropOverlay.classList.remove("hidden");
|
||||
dropOverlay.classList.add("flex");
|
||||
}, false);
|
||||
});
|
||||
|
||||
["dragleave", "drop"].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, (e) => {
|
||||
if (eventName === "drop" || e.target === dropZone || !dropZone.contains(e.relatedTarget)) {
|
||||
dropOverlay.classList.add("hidden");
|
||||
dropOverlay.classList.remove("flex");
|
||||
}
|
||||
}, false);
|
||||
});
|
||||
|
||||
dropZone.addEventListener("drop", (e) => {
|
||||
const dt = e.dataTransfer;
|
||||
const files = dt.files;
|
||||
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
handleFileSelect({ target: { files: [file] } });
|
||||
|
||||
const fileInput = document.getElementById("file-input");
|
||||
if (fileInput) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
fileInput.files = dataTransfer.files;
|
||||
}
|
||||
}
|
||||
}, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,208 +1,445 @@
|
||||
/**
|
||||
/*
|
||||
===============================================================================
|
||||
FILE: modules/web_api/static/js/chat/task_widget.js
|
||||
ROLE: Генеративный UI интерактивных карточек задач внутри диалога чата
|
||||
(фильтры, inline-чекбоксы статусов, быстрое добавление и удаление).
|
||||
ROLE: Интерактивные виджеты задач и срезов СКУД (SNAPSHOTS_CARD) с чекбоксами.
|
||||
===============================================================================
|
||||
*/
|
||||
|
||||
let activeWidgetTasksMap = new Map();
|
||||
window.activeTaskFilter = window.activeTaskFilter || 'IN_PROGRESS';
|
||||
window.currentTasksCache = window.currentTasksCache || [];
|
||||
|
||||
function renderTaskItemsHtml(tasks, filterStatus) {
|
||||
const filtered = tasks.filter(t => {
|
||||
if (filterStatus === "ALL") return true;
|
||||
return t.status === filterStatus;
|
||||
});
|
||||
function getAuthHeaders() {
|
||||
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (token) {
|
||||
headers['Authorization'] = 'Bearer ' + token;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
const label = filterStatus === 'BACKLOG' ? 'В планах' : filterStatus === 'IN_PROGRESS' ? 'В работе' : filterStatus === 'COMPLETED' ? 'Завершенные' : 'Все';
|
||||
return `<div class="text-slate-400 text-xs py-4 text-center">Нет задач со статусом «${label}»</div>`;
|
||||
window.sendChatAction = function(actionText) {
|
||||
const input = document.getElementById("user-input");
|
||||
if (input && typeof sendMessage === "function") {
|
||||
input.value = actionText;
|
||||
if (typeof setInputLocked === "function") setInputLocked(false);
|
||||
sendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// РЕНДЕРИНГ КАРТОЧКИ СРЕЗОВ СКУД (SNAPSHOTS_CARD) С ЧЕКБОКСАМИ
|
||||
// ============================================================================
|
||||
function renderSnapshotsCard(data) {
|
||||
if (!data || !data.snapshots || !Array.isArray(data.snapshots)) return '';
|
||||
const queryDate = data.query_date || 'выбранную дату';
|
||||
const snapshots = data.snapshots;
|
||||
|
||||
if (snapshots.length === 0) {
|
||||
return `
|
||||
<div class="p-6 bg-slate-50 border border-slate-200 rounded-xl text-center text-xs text-slate-500 my-2">
|
||||
📸 За дату <b>${queryDate}</b> сохраненных снапшотов не найдено.
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return filtered.map(t => {
|
||||
const isDone = t.status === "COMPLETED";
|
||||
const isProgress = t.status === "IN_PROGRESS";
|
||||
const checkedAttr = isDone ? "checked" : "";
|
||||
const textClass = isDone ? "line-through text-slate-400" : "text-slate-800 font-medium";
|
||||
const rowsHtml = snapshots.map((s, idx) => {
|
||||
const snapId = s.snapshot_id || `ID-${idx}`;
|
||||
const snapTime = s.snapshot_time ? s.snapshot_time.split(' ')[1] || s.snapshot_time : '—';
|
||||
const count = s.record_count || 0;
|
||||
const isFinal = snapId.startsWith('Y');
|
||||
|
||||
let statusPill = `<span class="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded border border-slate-200">В планах</span>`;
|
||||
if (isDone) {
|
||||
statusPill = `<span class="text-[10px] bg-emerald-50 text-emerald-700 px-2 py-0.5 rounded border border-emerald-300 font-semibold">Завершено</span>`;
|
||||
} else if (isProgress) {
|
||||
statusPill = `<span class="text-[10px] bg-amber-50 text-amber-700 px-2 py-0.5 rounded border border-amber-300 font-semibold">В работе</span>`;
|
||||
}
|
||||
|
||||
let prioPill = "";
|
||||
if (t.priority === "HIGH") {
|
||||
prioPill = `<span class="text-[9px] bg-red-50 text-red-700 font-bold px-1.5 py-0.5 rounded border border-red-200 uppercase">HIGH</span>`;
|
||||
if (isFinal) {
|
||||
return `
|
||||
<div class="p-2.5 bg-purple-50/60 rounded-lg border border-purple-200 shadow-sm flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<span class="w-4 flex justify-center text-purple-400" title="Итоговый срез защищен">
|
||||
<i class="fa-solid fa-lock text-[11px]"></i>
|
||||
</span>
|
||||
<span class="font-mono text-xs font-bold px-2 py-0.5 rounded bg-purple-100 text-purple-800 border border-purple-300">
|
||||
#${snapId}
|
||||
</span>
|
||||
<div class="flex items-center gap-3 text-xs text-slate-600">
|
||||
<span class="flex items-center gap-1 font-semibold text-purple-900">
|
||||
<i class="fa-regular fa-clock text-purple-600 text-[11px]"></i> ${snapTime}
|
||||
</span>
|
||||
<span class="flex items-center gap-1 text-slate-500">
|
||||
<i class="fa-solid fa-users text-slate-400 text-[11px]"></i> ${count} записей
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[10px] font-bold text-purple-700 bg-purple-100 border border-purple-200 px-2 py-0.5 rounded">
|
||||
Итоговый Y-срез
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="flex items-center justify-between p-2.5 bg-slate-50 hover:bg-slate-100/80 rounded-xl border border-slate-200 transition gap-2" id="widget-task-${t.task_id}">
|
||||
<div class="flex items-center gap-2.5 flex-1 min-w-0">
|
||||
<input type="checkbox" ${checkedAttr} onchange="toggleTaskStatusInline('${t.task_id}', this.checked)"
|
||||
class="w-4 h-4 text-indigo-600 rounded border-slate-300 focus:ring-indigo-500 cursor-pointer">
|
||||
<div class="flex flex-col min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5 mb-0.5">
|
||||
<span class="font-mono text-[11px] font-bold text-slate-700">${t.task_id}</span>
|
||||
${prioPill}
|
||||
${statusPill}
|
||||
</div>
|
||||
<span class="text-xs ${textClass} truncate leading-tight">${escapeHtml(t.title)}</span>
|
||||
<div class="p-2.5 bg-white rounded-lg border border-slate-200 shadow-sm hover:border-slate-300 transition flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<input type="checkbox" value="${snapId}" onchange="window.updateSelectedSnapshots(this)"
|
||||
class="snapshot-item-checkbox rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 w-4 h-4 cursor-pointer">
|
||||
<span class="font-mono text-xs font-bold px-2 py-0.5 rounded bg-slate-100 text-slate-700 border border-slate-200">
|
||||
#${snapId}
|
||||
</span>
|
||||
<div class="flex items-center gap-3 text-xs text-slate-600">
|
||||
<span class="flex items-center gap-1 font-semibold text-slate-800">
|
||||
<i class="fa-regular fa-clock text-indigo-500 text-[11px]"></i> ${snapTime}
|
||||
</span>
|
||||
<span class="flex items-center gap-1 text-slate-500">
|
||||
<i class="fa-solid fa-users text-slate-400 text-[11px]"></i> ${count} записей
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick="deleteTaskInline('${t.task_id}')" class="text-slate-400 hover:text-red-500 p-1.5 transition rounded-lg hover:bg-red-50" title="Удалить задачу">
|
||||
<button type="button" onclick="window.sendChatAction('удали снапшот ${snapId}')"
|
||||
class="p-1 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded border border-transparent hover:border-rose-200 transition"
|
||||
title="Удалить срез">
|
||||
<i class="fa-solid fa-trash-can text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function renderInteractiveTaskCard(tasks) {
|
||||
if (!tasks || !Array.isArray(tasks)) return "";
|
||||
const widgetId = "task_widget_" + Date.now();
|
||||
activeWidgetTasksMap.set(widgetId, { tasks: tasks, filter: "ALL" });
|
||||
|
||||
const itemsHtml = renderTaskItemsHtml(tasks, "ALL");
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="task-widget-card mt-3 bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm space-y-3" id="${widgetId}">
|
||||
<div class="flex items-center gap-1 pb-2 border-b border-slate-100 overflow-x-auto no-scrollbar text-xs font-semibold">
|
||||
<button type="button" onclick="filterTaskWidget('${widgetId}', 'ALL', this)" class="widget-tab-btn px-2.5 py-1 rounded-lg bg-indigo-50 text-indigo-600 font-bold border border-indigo-200">Все</button>
|
||||
<button type="button" onclick="filterTaskWidget('${widgetId}', 'IN_PROGRESS', this)" class="widget-tab-btn px-2.5 py-1 rounded-lg text-slate-500 hover:text-slate-700">В работе</button>
|
||||
<button type="button" onclick="filterTaskWidget('${widgetId}', 'BACKLOG', this)" class="widget-tab-btn px-2.5 py-1 rounded-lg text-slate-500 hover:text-slate-700">В планах</button>
|
||||
<button type="button" onclick="filterTaskWidget('${widgetId}', 'COMPLETED', this)" class="widget-tab-btn px-2.5 py-1 rounded-lg text-slate-500 hover:text-slate-700">Завершенные</button>
|
||||
<div class="snapshots-widget-root w-full max-w-4xl mx-auto my-2 bg-slate-50 border border-slate-300 rounded-xl shadow-md overflow-hidden flex flex-col">
|
||||
<div class="px-4 py-2.5 bg-white border-b border-slate-200 flex items-center justify-between gap-2 flex-wrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="bg-indigo-600 text-white p-1.5 rounded-lg flex items-center justify-center">
|
||||
<i class="fa-solid fa-camera text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs font-bold text-slate-800">Реестр срезов СКУД</span>
|
||||
<span class="text-xs text-slate-500 ml-1">за ${queryDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="flex items-center gap-1.5 text-xs text-slate-600 cursor-pointer select-none">
|
||||
<input type="checkbox" onchange="window.toggleSelectAllSnapshots(this)" class="select-all-snapshots-cb rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 w-3.5 h-3.5">
|
||||
<span>Выбрать все</span>
|
||||
</label>
|
||||
<span class="text-[11px] font-semibold bg-indigo-50 text-indigo-700 border border-indigo-200 px-2 py-0.5 rounded-full">
|
||||
Срезов: ${snapshots.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="widget-items-container space-y-2 max-h-72 overflow-y-auto pr-1">
|
||||
${itemsHtml}
|
||||
<div class="p-3 flex flex-col gap-2">
|
||||
${rowsHtml}
|
||||
</div>
|
||||
|
||||
<div class="pt-2 border-t border-slate-100 flex items-center gap-2">
|
||||
<input type="text" placeholder="Новая задача..." class="flex-1 bg-slate-50 border border-slate-200 rounded-xl px-3 py-1.5 text-xs text-slate-800 focus:outline-none focus:border-indigo-600 focus:bg-white transition" onkeydown="if(event.key==='Enter') addTaskInline('${widgetId}', this)">
|
||||
<button type="button" onclick="addTaskInline('${widgetId}', this.previousElementSibling)" class="bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white px-3 py-1.5 rounded-xl text-xs font-semibold shadow-sm transition flex items-center gap-1">
|
||||
<i class="fa-solid fa-plus text-[10px]"></i> Добавить
|
||||
<!-- ПОДВАЛ С КНОПКОЙ УДАЛЕНИЯ ВЫБРАННЫХ -->
|
||||
<div class="snapshot-bulk-actions-footer hidden px-4 py-2 bg-rose-50/70 border-t border-rose-200 flex items-center justify-between">
|
||||
<span class="text-xs text-rose-800 font-medium bulk-selected-counter">Выбрано: 0</span>
|
||||
<button type="button" onclick="window.submitBulkDeleteSnapshots(this)"
|
||||
class="px-3 py-1.5 bg-rose-600 hover:bg-rose-700 active:bg-rose-800 text-white font-bold rounded-lg text-xs flex items-center gap-1.5 transition shadow-sm">
|
||||
<i class="fa-solid fa-trash-can"></i>
|
||||
<span>Удалить выбранные</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function filterTaskWidget(widgetId, status, btnEl) {
|
||||
const state = activeWidgetTasksMap.get(widgetId);
|
||||
if (!state) return;
|
||||
state.filter = status;
|
||||
// ⭐️ Обработчики чекбоксов
|
||||
window.toggleSelectAllSnapshots = function(masterCb) {
|
||||
const root = masterCb.closest('.snapshots-widget-root');
|
||||
if (!root) return;
|
||||
const checkboxes = root.querySelectorAll('.snapshot-item-checkbox');
|
||||
checkboxes.forEach(cb => cb.checked = masterCb.checked);
|
||||
window.syncBulkDeleteFooter(root);
|
||||
};
|
||||
|
||||
const widgetEl = document.getElementById(widgetId);
|
||||
if (!widgetEl) return;
|
||||
window.updateSelectedSnapshots = function(itemCb) {
|
||||
const root = itemCb.closest('.snapshots-widget-root');
|
||||
if (!root) return;
|
||||
window.syncBulkDeleteFooter(root);
|
||||
};
|
||||
|
||||
widgetEl.querySelectorAll(".widget-tab-btn").forEach(b => {
|
||||
b.className = "widget-tab-btn px-2.5 py-1 rounded-lg text-slate-500 hover:text-slate-700";
|
||||
window.syncBulkDeleteFooter = function(root) {
|
||||
const checkboxes = root.querySelectorAll('.snapshot-item-checkbox:checked');
|
||||
const footer = root.querySelector('.snapshot-bulk-actions-footer');
|
||||
const counter = root.querySelector('.bulk-selected-counter');
|
||||
const masterCb = root.querySelector('.select-all-snapshots-cb');
|
||||
const allCheckboxes = root.querySelectorAll('.snapshot-item-checkbox');
|
||||
|
||||
if (masterCb) {
|
||||
masterCb.checked = allCheckboxes.length > 0 && checkboxes.length === allCheckboxes.length;
|
||||
}
|
||||
|
||||
if (checkboxes.length > 0) {
|
||||
if (footer) footer.classList.remove('hidden');
|
||||
if (counter) counter.innerText = `Выбрано дневных срезов: ${checkboxes.length}`;
|
||||
} else {
|
||||
if (footer) footer.classList.add('hidden');
|
||||
}
|
||||
};
|
||||
|
||||
// Одиночная корзина в строке снапшота:
|
||||
// onclick="window.sendChatAction('удали снапшот ${snapId}')"
|
||||
|
||||
// Кнопка пакетного удаления в подвале карточки:
|
||||
window.submitBulkDeleteSnapshots = function(btnEl) {
|
||||
const root = btnEl.closest('.snapshots-widget-root');
|
||||
if (!root) return;
|
||||
const selected = Array.from(root.querySelectorAll('.snapshot-item-checkbox:checked')).map(cb => cb.value);
|
||||
if (selected.length === 0) return;
|
||||
window.sendChatAction(`удали снапшоты ${selected.join(', ')}`);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// РЕНДЕРИНГ КАРТОЧКИ ЗАДАЧ (TASK_INTERACTIVE_CARD)
|
||||
// ============================================================================
|
||||
function renderInteractiveTaskCard(tasks) {
|
||||
if (!tasks || !Array.isArray(tasks)) return '';
|
||||
window.currentTasksCache = tasks;
|
||||
|
||||
const counts = {
|
||||
ALL: tasks.length,
|
||||
IN_PROGRESS: tasks.filter(t => t.status === 'IN_PROGRESS' || t.status === 'PROGRESS').length,
|
||||
PLANNED: tasks.filter(t => t.status === 'BACKLOG' || t.status === 'PLANNED').length,
|
||||
COMPLETED: tasks.filter(t => t.status === 'COMPLETED' || t.status === 'DONE').length
|
||||
};
|
||||
|
||||
const currentFilter = window.activeTaskFilter || 'IN_PROGRESS';
|
||||
|
||||
const filteredTasks = tasks.filter(t => {
|
||||
const s = (t.status || 'BACKLOG').toUpperCase();
|
||||
if (currentFilter === 'ALL') return true;
|
||||
if (currentFilter === 'IN_PROGRESS') return s === 'IN_PROGRESS' || s === 'PROGRESS';
|
||||
if (currentFilter === 'PLANNED') return s === 'BACKLOG' || s === 'PLANNED';
|
||||
if (currentFilter === 'COMPLETED') return s === 'COMPLETED' || s === 'DONE';
|
||||
return true;
|
||||
});
|
||||
btnEl.className = "widget-tab-btn px-2.5 py-1 rounded-lg bg-indigo-50 text-indigo-600 font-bold border border-indigo-200";
|
||||
|
||||
const container = widgetEl.querySelector(".widget-items-container");
|
||||
if (container) {
|
||||
container.innerHTML = renderTaskItemsHtml(state.tasks, status);
|
||||
}
|
||||
const getPrioBadge = (prio) => {
|
||||
const p = (prio || 'MEDIUM').toUpperCase();
|
||||
if (p === 'HIGH' || p === 'CRITICAL') return '<span class="text-[10px] px-2 py-0.5 rounded font-bold bg-rose-100 text-rose-700 border border-rose-200">🔥 HIGH</span>';
|
||||
if (p === 'LOW') return '<span class="text-[10px] px-2 py-0.5 rounded font-semibold bg-slate-100 text-slate-600 border border-slate-200">☕ LOW</span>';
|
||||
return '<span class="text-[10px] px-2 py-0.5 rounded font-semibold bg-amber-100 text-amber-700 border border-amber-200">⚡ MEDIUM</span>';
|
||||
};
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
const s = (status || 'BACKLOG').toUpperCase();
|
||||
if (s === 'IN_PROGRESS' || s === 'PROGRESS') return '<span class="text-[10px] px-2 py-0.5 rounded font-bold bg-blue-50 text-blue-700 border border-blue-200">⚙️ В работе</span>';
|
||||
if (s === 'COMPLETED' || s === 'DONE') return '<span class="text-[10px] px-2 py-0.5 rounded font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">✓ Готово</span>';
|
||||
return '<span class="text-[10px] px-2 py-0.5 rounded font-semibold bg-slate-50 text-slate-600 border border-slate-200">📋 В планах</span>';
|
||||
};
|
||||
|
||||
const taskRows = filteredTasks.map(t => {
|
||||
const id = t.id;
|
||||
const title = t.title || 'Без названия';
|
||||
const isDone = t.status === 'COMPLETED' || t.status === 'DONE';
|
||||
const isInProgress = t.status === 'IN_PROGRESS' || t.status === 'PROGRESS';
|
||||
|
||||
const actionBtn = isInProgress
|
||||
? `<button type="button" onclick="window.sendChatAction('заверши задачу ${id}')" class="px-2.5 py-1 text-xs font-semibold rounded bg-emerald-50 text-emerald-700 border border-emerald-300 hover:bg-emerald-100 transition-colors shadow-sm" title="Завершить задачу">✓ Готово</button>`
|
||||
: (!isDone
|
||||
? `<button type="button" onclick="window.sendChatAction('возьми в работу задачу ${id}')" class="px-2.5 py-1 text-xs font-semibold rounded bg-blue-50 text-blue-700 border border-blue-300 hover:bg-blue-100 transition-colors shadow-sm" title="Взять в работу">⚙️ В работу</button>`
|
||||
: '');
|
||||
|
||||
return `
|
||||
<div id="task-card-${id}" class="p-3 bg-white rounded-lg border border-slate-200 shadow-sm hover:border-slate-300 transition-all flex flex-col gap-2">
|
||||
<div class="task-view-mode flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2 flex-wrap flex-1 min-w-0">
|
||||
<span class="text-xs font-bold px-1.5 py-0.5 rounded bg-slate-100 text-slate-700 border border-slate-200">#${id}</span>
|
||||
<span class="text-sm font-medium text-slate-900 truncate" title="${title}">${title}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
${actionBtn}
|
||||
<button type="button" onclick="window.openTaskInlineEditor(${id})" class="p-1 text-xs text-slate-500 hover:text-indigo-600 hover:bg-slate-50 rounded border border-slate-200 transition-colors" title="Редактировать">✏️</button>
|
||||
<button type="button" onclick="window.sendChatAction('удали задачу ${id}')" class="p-1 text-xs text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded border border-slate-200 transition-colors" title="Удалить">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-view-mode flex items-center gap-2 text-xs text-slate-500 flex-wrap">
|
||||
${getPrioBadge(t.priority)}
|
||||
${getStatusBadge(t.status)}
|
||||
<span class="px-1.5 py-0.5 rounded bg-slate-50 border border-slate-200 text-slate-600 font-mono text-[11px]">${t.module || 'general'}</span>
|
||||
${t.due_date ? `<span class="text-slate-500">📅 срок: <b>${t.due_date}</b></span>` : ''}
|
||||
${t.created_at ? `<span class="text-slate-400">создана: ${t.created_at.split(' ')[0]}</span>` : ''}
|
||||
</div>
|
||||
|
||||
<!-- ФОРМА ИНЛАЙН РЕДАКТИРОВАНИЯ -->
|
||||
<div id="task-editor-${id}" class="hidden flex flex-col gap-2 pt-2 border-t border-slate-100">
|
||||
<input type="text" id="task-edit-title-${id}" value="${title.replace(/"/g, '"')}" class="w-full text-xs px-2.5 py-1.5 border border-slate-300 rounded focus:border-indigo-500 focus:outline-none bg-slate-50" placeholder="Описание задачи..." />
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<input type="date" id="task-edit-date-${id}" value="${t.due_date || ''}" class="text-xs px-2 py-1 border border-slate-300 rounded focus:border-indigo-500 focus:outline-none bg-slate-50" />
|
||||
<select id="task-edit-prio-${id}" class="text-xs px-2 py-1 border border-slate-300 rounded focus:border-indigo-500 focus:outline-none bg-slate-50">
|
||||
<option value="LOW" ${t.priority === 'LOW' ? 'selected' : ''}>☕ LOW</option>
|
||||
<option value="MEDIUM" ${t.priority === 'MEDIUM' || !t.priority ? 'selected' : ''}>⚡ MEDIUM</option>
|
||||
<option value="HIGH" ${t.priority === 'HIGH' ? 'selected' : ''}>🔥 HIGH</option>
|
||||
<option value="CRITICAL" ${t.priority === 'CRITICAL' ? 'selected' : ''}>🚨 CRITICAL</option>
|
||||
</select>
|
||||
<button type="button" onclick="window.saveTaskInlineEdit(${id})" class="px-2.5 py-1 text-xs font-semibold rounded bg-indigo-600 text-white hover:bg-indigo-700 transition-colors shadow-sm">Сохранить</button>
|
||||
<button type="button" onclick="window.closeTaskInlineEditor(${id})" class="px-2 py-1 text-xs font-medium rounded text-slate-500 hover:bg-slate-100 transition-colors">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const emptyState = `
|
||||
<div class="p-8 text-center text-slate-400">
|
||||
<div class="text-3xl mb-2">📭</div>
|
||||
<div class="text-sm font-medium">Нет задач в категории «${currentFilter}»</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return `
|
||||
<div class="task-widget-root w-full max-w-4xl mx-auto my-2 bg-slate-50 border border-slate-300 rounded-xl shadow-md overflow-hidden flex flex-col">
|
||||
<div class="px-4 py-3 bg-white border-b border-slate-200 flex items-center justify-between gap-2 flex-wrap">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<button type="button" onclick="window.switchTaskFilter('IN_PROGRESS', this)" class="px-2.5 py-1 text-xs font-bold rounded-md transition-colors ${currentFilter === 'IN_PROGRESS' ? 'bg-blue-600 text-white shadow-sm' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}">
|
||||
⚙️ В работе (${counts.IN_PROGRESS})
|
||||
</button>
|
||||
<button type="button" onclick="window.switchTaskFilter('PLANNED', this)" class="px-2.5 py-1 text-xs font-bold rounded-md transition-colors ${currentFilter === 'PLANNED' ? 'bg-indigo-600 text-white shadow-sm' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}">
|
||||
📋 В планах (${counts.PLANNED})
|
||||
</button>
|
||||
<button type="button" onclick="window.switchTaskFilter('COMPLETED', this)" class="px-2.5 py-1 text-xs font-bold rounded-md transition-colors ${currentFilter === 'COMPLETED' ? 'bg-emerald-600 text-white shadow-sm' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}">
|
||||
✓ Готово (${counts.COMPLETED})
|
||||
</button>
|
||||
<button type="button" onclick="window.switchTaskFilter('ALL', this)" class="px-2.5 py-1 text-xs font-bold rounded-md transition-colors ${currentFilter === 'ALL' ? 'bg-slate-800 text-white shadow-sm' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}">
|
||||
Все (${counts.ALL})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="button" onclick="window.toggleCreateTaskForm(this)" class="px-3 py-1.5 text-xs font-bold rounded-md bg-indigo-600 hover:bg-indigo-700 text-white transition-all shadow-sm flex items-center gap-1">
|
||||
➕ Добавить задачу
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="new-task-creation-form hidden p-3 bg-indigo-50/70 border-b border-indigo-100 flex flex-col gap-2">
|
||||
<div class="text-xs font-bold text-indigo-900">Новая задача:</div>
|
||||
<input type="text" class="new-task-title w-full text-xs px-2.5 py-1.5 border border-indigo-200 rounded focus:border-indigo-500 focus:outline-none bg-white" placeholder="Что необходимо сделать?..." />
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<input type="date" class="new-task-date text-xs px-2 py-1 border border-indigo-200 rounded focus:border-indigo-500 focus:outline-none bg-white" />
|
||||
<select class="new-task-prio text-xs px-2 py-1 border border-indigo-200 rounded focus:border-indigo-500 focus:outline-none bg-white">
|
||||
<option value="LOW">☕ LOW (Низкий)</option>
|
||||
<option value="MEDIUM" selected>⚡ MEDIUM (Средний)</option>
|
||||
<option value="HIGH">🔥 HIGH (Высокий)</option>
|
||||
<option value="CRITICAL">🚨 CRITICAL (Критический)</option>
|
||||
</select>
|
||||
<select class="new-task-status text-xs px-2 py-1 border border-indigo-200 rounded focus:border-indigo-500 focus:outline-none bg-white">
|
||||
<option value="BACKLOG" selected>📋 В планы (Бэклог)</option>
|
||||
<option value="IN_PROGRESS">⚙️ Сразу в работу</option>
|
||||
</select>
|
||||
<button type="button" onclick="window.submitCreateTask(this)" class="px-3 py-1 text-xs font-bold rounded bg-indigo-600 text-white hover:bg-indigo-700 transition-colors shadow-sm">Создать</button>
|
||||
<button type="button" onclick="window.toggleCreateTaskForm(this)" class="px-2 py-1 text-xs font-medium rounded text-slate-500 hover:bg-slate-200 transition-colors">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-3 overflow-y-auto max-h-[70vh] flex flex-col gap-2 task-rows-container">
|
||||
${filteredTasks.length > 0 ? taskRows : emptyState}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function toggleTaskStatusInline(taskId, isChecked) {
|
||||
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
|
||||
const newStatus = isChecked ? "COMPLETED" : "BACKLOG";
|
||||
window.switchTaskFilter = function(filterName, btnEl) {
|
||||
window.activeTaskFilter = filterName;
|
||||
const root = btnEl ? btnEl.closest('.task-widget-root') : document.querySelector('.task-widget-root');
|
||||
if (root && window.currentTasksCache && window.currentTasksCache.length > 0) {
|
||||
root.outerHTML = renderInteractiveTaskCard(window.currentTasksCache);
|
||||
}
|
||||
};
|
||||
|
||||
window.toggleCreateTaskForm = function(btnEl) {
|
||||
const root = btnEl ? btnEl.closest('.task-widget-root') : document.querySelector('.task-widget-root');
|
||||
if (!root) return;
|
||||
const form = root.querySelector('.new-task-creation-form');
|
||||
if (form) {
|
||||
form.classList.toggle('hidden');
|
||||
if (!form.classList.contains('hidden')) {
|
||||
const input = form.querySelector('.new-task-title');
|
||||
if (input) input.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.submitCreateTask = async function(btnEl) {
|
||||
const root = btnEl ? btnEl.closest('.task-widget-root') : document.querySelector('.task-widget-root');
|
||||
if (!root) return;
|
||||
|
||||
const titleInput = root.querySelector('.new-task-title');
|
||||
const dateInput = root.querySelector('.new-task-date');
|
||||
const prioInput = root.querySelector('.new-task-prio');
|
||||
const statusInput = root.querySelector('.new-task-status');
|
||||
|
||||
if (!titleInput || !titleInput.value.trim()) {
|
||||
alert('Введите описание задачи');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/tasks/${taskId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer " + token
|
||||
},
|
||||
body: JSON.stringify({ status: newStatus })
|
||||
const res = await fetch('/api/v1/tasks', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
title: titleInput.value.trim(),
|
||||
due_date: dateInput ? (dateInput.value || null) : null,
|
||||
priority: prioInput ? prioInput.value : 'MEDIUM',
|
||||
status: statusInput ? statusInput.value : 'BACKLOG'
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
for (let [wId, state] of activeWidgetTasksMap.entries()) {
|
||||
const targetTask = state.tasks.find(t => t.task_id === taskId);
|
||||
if (targetTask) {
|
||||
targetTask.status = newStatus;
|
||||
const widgetEl = document.getElementById(wId);
|
||||
const container = widgetEl?.querySelector(".widget-items-container");
|
||||
if (container) {
|
||||
container.innerHTML = renderTaskItemsHtml(state.tasks, state.filter);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof loadTasks === "function") loadTasks();
|
||||
window.activeTaskFilter = (statusInput && statusInput.value === 'IN_PROGRESS') ? 'IN_PROGRESS' : 'PLANNED';
|
||||
window.sendChatAction('покажи задачи');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert('Ошибка создания задачи: ' + (err.detail || 'Неизвестная ошибка'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Task Patch Error]", err);
|
||||
} catch (e) {
|
||||
console.error('Ошибка создания задачи:', e);
|
||||
alert('Сетевая ошибка при создании задачи');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function deleteTaskInline(taskId) {
|
||||
if (!confirm(`Удалить задачу ${taskId}?`)) return;
|
||||
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
|
||||
window.openTaskInlineEditor = function(id) {
|
||||
const card = document.getElementById(`task-card-${id}`);
|
||||
if (!card) return;
|
||||
card.querySelectorAll('.task-view-mode').forEach(el => el.classList.add('hidden'));
|
||||
const editor = document.getElementById(`task-editor-${id}`);
|
||||
if (editor) editor.classList.remove('hidden');
|
||||
};
|
||||
|
||||
window.closeTaskInlineEditor = function(id) {
|
||||
const card = document.getElementById(`task-card-${id}`);
|
||||
if (!card) return;
|
||||
card.querySelectorAll('.task-view-mode').forEach(el => el.classList.remove('hidden'));
|
||||
const editor = document.getElementById(`task-editor-${id}`);
|
||||
if (editor) editor.classList.add('hidden');
|
||||
};
|
||||
|
||||
window.saveTaskInlineEdit = async function(id) {
|
||||
const titleInput = document.getElementById(`task-edit-title-${id}`);
|
||||
const dateInput = document.getElementById(`task-edit-date-${id}`);
|
||||
const prioInput = document.getElementById(`task-edit-prio-${id}`);
|
||||
|
||||
if (!titleInput || !titleInput.value.trim()) {
|
||||
alert('Описание задачи не может быть пустым');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/tasks/${taskId}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Authorization": "Bearer " + token }
|
||||
const res = await fetch(`/api/v1/tasks/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
title: titleInput.value.trim(),
|
||||
due_date: dateInput ? (dateInput.value || null) : null,
|
||||
priority: prioInput ? prioInput.value : 'MEDIUM'
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
for (let [wId, state] of activeWidgetTasksMap.entries()) {
|
||||
state.tasks = state.tasks.filter(t => t.task_id !== taskId);
|
||||
const widgetEl = document.getElementById(wId);
|
||||
const container = widgetEl?.querySelector(".widget-items-container");
|
||||
if (container) {
|
||||
container.innerHTML = renderTaskItemsHtml(state.tasks, state.filter);
|
||||
}
|
||||
}
|
||||
if (typeof loadTasks === "function") loadTasks();
|
||||
window.sendChatAction('покажи задачи');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert('Ошибка обновления задачи: ' + (err.detail || 'Неизвестная ошибка'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Task Delete Error]", err);
|
||||
} catch (e) {
|
||||
console.error('Ошибка сохранения задачи:', e);
|
||||
alert('Сетевая ошибка при обновлении задачи');
|
||||
}
|
||||
}
|
||||
|
||||
async function addTaskInline(widgetId, inputEl) {
|
||||
if (!inputEl) return;
|
||||
const title = inputEl.value.trim();
|
||||
if (!title) return;
|
||||
|
||||
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/v1/tasks", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer " + token
|
||||
},
|
||||
body: JSON.stringify({ title: title, priority: "MEDIUM", module: "general" })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
inputEl.value = "";
|
||||
const tasksRes = await fetch("/api/v1/tasks", {
|
||||
headers: { "Authorization": "Bearer " + token }
|
||||
});
|
||||
const updatedTasks = await tasksRes.json();
|
||||
|
||||
const state = activeWidgetTasksMap.get(widgetId);
|
||||
if (state) {
|
||||
state.tasks = updatedTasks;
|
||||
const widgetEl = document.getElementById(widgetId);
|
||||
const container = widgetEl?.querySelector(".widget-items-container");
|
||||
if (container) {
|
||||
container.innerHTML = renderTaskItemsHtml(updatedTasks, state.filter);
|
||||
}
|
||||
}
|
||||
if (typeof loadTasks === "function") loadTasks();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Task Add Error]", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,164 +1,117 @@
|
||||
/**
|
||||
===============================================================================
|
||||
FILE: modules/web_api/static/js/tasks.js
|
||||
ROLE: Управление боковой панелью задач (Drawer), фильтрация и рендеринг карточек.
|
||||
|
||||
AI-CONTEXT-ANCHORS:
|
||||
- ANCHOR[DRAWER_TOGGLE]: Открытие и закрытие выезжающей панели.
|
||||
- ANCHOR[TASKS_FILTER]: Фильтрация списка (ALL, IN_PROGRESS, BACKLOG, COMPLETED).
|
||||
- ANCHOR[TASKS_LOAD_FETCH]: Асинхронная загрузка задач через /api/v1/tasks.
|
||||
- ANCHOR[TASKS_RENDER_DOM]: Генерация HTML-карточек в боковом меню.
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: web_api / static / js
|
||||
ROLE: Загрузка, фильтрация и рендеринг списка задач в боковой панели (Drawer).
|
||||
===============================================================================
|
||||
*/
|
||||
|
||||
let currentFilter = 'ALL';
|
||||
let allTasks = [];
|
||||
let currentTaskFilter = 'IN_PROGRESS';
|
||||
|
||||
// --- [SECTION 1: DRAWER TOGGLE] --- # ANCHOR[DRAWER_TOGGLE]
|
||||
function toggleDrawer() {
|
||||
if (typeof IS_GUEST !== 'undefined' && IS_GUEST) return;
|
||||
const drawer = document.getElementById("task-drawer");
|
||||
const backdrop = document.getElementById("drawer-backdrop");
|
||||
if (!drawer) return;
|
||||
|
||||
const isHidden = drawer.classList.contains("translate-x-full");
|
||||
if (isHidden) {
|
||||
drawer.classList.remove("translate-x-full");
|
||||
if (backdrop) backdrop.classList.remove("hidden");
|
||||
loadTasks();
|
||||
} else {
|
||||
drawer.classList.add("translate-x-full");
|
||||
if (backdrop) backdrop.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
// --- [SECTION 2: FILTER CONTROL] --- # ANCHOR[TASKS_FILTER]
|
||||
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();
|
||||
}
|
||||
|
||||
// --- [SECTION 3: ASYNC DATA FETCH] --- # ANCHOR[TASKS_LOAD_FETCH]
|
||||
async function loadTasks() {
|
||||
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;
|
||||
}
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/v1/tasks", {
|
||||
headers: {
|
||||
"Authorization": "Bearer " + token,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
headers: { "Authorization": "Bearer " + token }
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
if (typeof logout === 'function') logout();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
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)) || [];
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
renderSidebarTasks(data.tasks || []);
|
||||
} else {
|
||||
allTasks = [];
|
||||
}
|
||||
|
||||
if (badge) {
|
||||
badge.innerText = allTasks.length.toString();
|
||||
}
|
||||
|
||||
renderTasks();
|
||||
|
||||
} catch (err) {
|
||||
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>`;
|
||||
renderSidebarError("Ошибка доступа. Авторизуйтесь снова.");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Ошибка загрузки задач:", e);
|
||||
renderSidebarError("Ошибка сети. Сервер недоступен.");
|
||||
}
|
||||
}
|
||||
|
||||
// --- [SECTION 4: DOM RENDERING] --- # ANCHOR[TASKS_RENDER_DOM]
|
||||
function renderTasks() {
|
||||
const container = document.getElementById("tasks-container");
|
||||
function renderSidebarError(msg) {
|
||||
// Поддерживаем оба варианта ID (новый и старый) для обратной совместимости
|
||||
const container = document.getElementById("tasks-list") || document.getElementById("tasks-list-container");
|
||||
if (container) {
|
||||
container.innerHTML = `<div class="text-center py-8 text-xs text-rose-500 font-semibold">${msg}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function filterTasksByTab(status) {
|
||||
currentTaskFilter = status;
|
||||
|
||||
// Сброс стилей всех кнопок-вкладок
|
||||
document.querySelectorAll('.task-tab-btn').forEach(btn => {
|
||||
btn.classList.remove('text-indigo-600', 'bg-indigo-50');
|
||||
btn.classList.add('text-slate-600');
|
||||
});
|
||||
|
||||
// Установка активного стиля для выбранной вкладки
|
||||
const activeBtnId = {
|
||||
'IN_PROGRESS': 'tab-in-progress',
|
||||
'BACKLOG': 'tab-backlog',
|
||||
'COMPLETED': 'tab-completed',
|
||||
'ALL': 'tab-all'
|
||||
}[status];
|
||||
|
||||
if (activeBtnId) {
|
||||
const btn = document.getElementById(activeBtnId);
|
||||
if (btn) {
|
||||
btn.classList.remove('text-slate-600', 'hover:bg-slate-100');
|
||||
btn.classList.add('text-indigo-600', 'bg-indigo-50');
|
||||
}
|
||||
}
|
||||
|
||||
loadTasks();
|
||||
}
|
||||
|
||||
function renderSidebarTasks(tasks) {
|
||||
const container = document.getElementById("tasks-list") || document.getElementById("tasks-list-container");
|
||||
if (!container) return;
|
||||
|
||||
if (!Array.isArray(allTasks)) {
|
||||
allTasks = [];
|
||||
let filtered = tasks;
|
||||
if (currentTaskFilter !== 'ALL') {
|
||||
filtered = tasks.filter(t => t.status === currentTaskFilter);
|
||||
}
|
||||
|
||||
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>`;
|
||||
container.innerHTML = `<div class="text-center py-8 text-[11px] font-medium text-slate-400 bg-slate-50 rounded-xl border border-dashed border-slate-200">Нет задач в этой категории</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = filtered.map(t => {
|
||||
let statusBadge = "bg-slate-100 text-slate-600 border-slate-200";
|
||||
let statusLabel = "В планах";
|
||||
let cardBg = "bg-white";
|
||||
const isCompleted = t.status === 'COMPLETED';
|
||||
const priorityColor = t.priority === 'HIGH' || t.priority === 'CRITICAL'
|
||||
? 'text-rose-600 bg-rose-50 border-rose-200'
|
||||
: t.priority === 'MEDIUM'
|
||||
? 'text-amber-600 bg-amber-50 border-amber-200'
|
||||
: 'text-slate-600 bg-slate-50 border-slate-200';
|
||||
|
||||
if (t.status === "COMPLETED") {
|
||||
statusBadge = "bg-emerald-50 text-emerald-700 border-emerald-300 font-semibold";
|
||||
statusLabel = "Завершено";
|
||||
cardBg = "bg-emerald-50/20";
|
||||
} else if (t.status === "IN_PROGRESS") {
|
||||
statusBadge = "bg-amber-50 text-amber-700 border-amber-300 font-bold";
|
||||
statusLabel = "В работе";
|
||||
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 || t.id || 'TASK'}</span>
|
||||
<span class="text-[10px] uppercase px-1.5 py-0.5 rounded border ${priorityBadge}">${t.priority || 'MEDIUM'}</span>
|
||||
</div>
|
||||
<span class="text-[10px] uppercase px-2 py-0.5 rounded border ${statusBadge}">${statusLabel}</span>
|
||||
<div class="p-3 bg-white border border-slate-200 rounded-xl shadow-sm hover:border-indigo-400 hover:shadow-md transition cursor-pointer flex flex-col gap-2 group"
|
||||
onclick="handleActionButtonClick('покажи задачу ${t.id}')">
|
||||
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-wider group-hover:text-indigo-500 transition">#${t.id}</span>
|
||||
${isCompleted
|
||||
? `<span class="text-[10px] font-bold px-1.5 py-0.5 rounded-md bg-emerald-50 text-emerald-600 border border-emerald-200 shadow-sm"><i class="fa-solid fa-check mr-0.5"></i> Готово</span>`
|
||||
: `<span class="text-[10px] font-bold px-1.5 py-0.5 rounded-md ${priorityColor} shadow-sm">${t.priority || 'LOW'}</span>`
|
||||
}
|
||||
</div>
|
||||
<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">
|
||||
<i class="fa-solid fa-folder-closed text-slate-300"></i>
|
||||
<span>${t.module || 'General'}</span>
|
||||
|
||||
<div class="text-xs font-semibold text-slate-700 leading-snug line-clamp-3">${escapeHtml(t.title || 'Без названия')}</div>
|
||||
|
||||
<div class="flex items-center justify-between text-[10px] text-slate-400 mt-1">
|
||||
<span class="bg-slate-100 px-1.5 py-0.5 rounded font-mono truncate max-w-[120px]">${escapeHtml(t.module || 'general')}</span>
|
||||
${t.due_date ? `<span class="shrink-0 font-medium text-slate-500"><i class="fa-regular fa-calendar mr-1"></i>${escapeHtml(t.due_date)}</span>` : ''}
|
||||
</div>
|
||||
${dueDateHtml}
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Глобальная инициализация при загрузке DOM
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// Небольшая задержка, чтобы гарантировать применение токена
|
||||
setTimeout(loadTasks, 200);
|
||||
});
|
||||
Reference in New Issue
Block a user