feat(ui): gemini-style top-anchored scroll, centered chat layout and task drawer sync (closes #49)
This commit is contained in:
@@ -0,0 +1,178 @@
|
|||||||
|
"""
|
||||||
|
===============================================================================
|
||||||
|
FILE: modules/ai_engine/agent.py
|
||||||
|
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||||
|
MODULE: modules / ai_engine
|
||||||
|
ROLE: Лаконичный нативный оркестратор Function Calling, диспетчер handlers
|
||||||
|
и менеджер свободных диалогов (Topic Drift).
|
||||||
|
|
||||||
|
AI-CONTEXT-ANCHORS:
|
||||||
|
- ANCHOR[AGENT_PIPELINE_ENTRY]: Главная точка входа process_chat_message.
|
||||||
|
===============================================================================
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import List, Dict, Any, Tuple, Optional
|
||||||
|
|
||||||
|
from modules.web_api.llm.schemas import TOOLS_SCHEMA
|
||||||
|
from modules.web_api.llm.core.ollama_client import call_ollama_chat
|
||||||
|
from modules.web_api.llm.core.fast_path import handle_fast_path_intercept
|
||||||
|
from modules.web_api.llm.core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed
|
||||||
|
from modules.web_api.llm.core.context_manager import mark_last_user_message_ephemeral, close_tool_session_and_cleanup
|
||||||
|
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_get_session_state, db_clear_session_state, db_set_session_state,
|
||||||
|
db_get_stats, db_get_anomalies, db_get_reference
|
||||||
|
)
|
||||||
|
from services.knowledge.service import get_rules
|
||||||
|
|
||||||
|
from .context_builder import build_agent_system_context
|
||||||
|
from .handlers.task_handler import handle_tasks_call
|
||||||
|
from .handlers.prompt_handler import handle_prompt_call
|
||||||
|
from .handlers.snapshot_handler import handle_snapshots_call
|
||||||
|
|
||||||
|
logger = logging.getLogger("AI_AGENT")
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
# ANCHOR[AGENT_PIPELINE_ENTRY]
|
||||||
|
def process_chat_message(
|
||||||
|
user_id: int,
|
||||||
|
user_message: str,
|
||||||
|
file_context: str = "",
|
||||||
|
image_b64: Optional[str] = None,
|
||||||
|
chat_history: List[Dict[str, Any]] = None,
|
||||||
|
session_id: str = "web_session_main"
|
||||||
|
) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||||
|
"""Главный конвейер обработки сообщений чата."""
|
||||||
|
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
|
||||||
|
|
||||||
|
# 1. Быстрый Fast-Path перехват кнопок подтверждения
|
||||||
|
fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state)
|
||||||
|
if fast_path_res:
|
||||||
|
return fast_path_res
|
||||||
|
|
||||||
|
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
|
||||||
|
db_history = db_get_chat_history(session_id, limit=20)
|
||||||
|
system_prompt = build_agent_system_context(user_id, session_state)
|
||||||
|
|
||||||
|
user_msg_obj = {"role": "user", "content": full_user_content}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if image_b64:
|
||||||
|
user_msg_obj["images"] = [image_b64]
|
||||||
|
messages = [{"role": "system", "content": "Строгий модуль OCR. Перепиши весь текст буква в букву."}, user_msg_obj]
|
||||||
|
msg = call_ollama_chat(messages, is_vision=True)
|
||||||
|
else:
|
||||||
|
clean_history = [dict(m) for m in db_history]
|
||||||
|
for m in clean_history: m.pop("images", None)
|
||||||
|
messages = [{"role": "system", "content": system_prompt}] + clean_history + [user_msg_obj]
|
||||||
|
msg = call_ollama_chat(messages, tools=TOOLS_SCHEMA, is_vision=False)
|
||||||
|
|
||||||
|
raw_reply = msg.get("content", "")
|
||||||
|
tool_calls = msg.get("tool_calls", [])
|
||||||
|
|
||||||
|
# 2. Гибридный семантический классификатор намерений (Fallback Safety Net)
|
||||||
|
tool_calls = inject_tools_if_needed(user_message, raw_reply, tool_calls)
|
||||||
|
|
||||||
|
# 3. Исполнение инструментов через изолированные handlers
|
||||||
|
if tool_calls:
|
||||||
|
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 = {}
|
||||||
|
|
||||||
|
logger.info(f"Вызов инструмента: {fn_name} с аргументами: {fn_args}")
|
||||||
|
close_tool_session_and_cleanup(session_id, close_reason=f"ACTIVATE_{fn_name}")
|
||||||
|
mark_last_user_message_ephemeral(session_id)
|
||||||
|
|
||||||
|
state_data = session_state.get("data_json") or {} if session_state else {}
|
||||||
|
|
||||||
|
if fn_name in ["db_get_tasks", "db_tasks_edit", "db_add_task", "db_update_task_status", "db_delete_task"]:
|
||||||
|
return handle_tasks_call(fn_name, fn_args, user_id, session_id)
|
||||||
|
|
||||||
|
elif fn_name in ["db_get_system_prompt", "db_prompt_node_edit"]:
|
||||||
|
return handle_prompt_call(fn_name, fn_args, session_id)
|
||||||
|
|
||||||
|
elif fn_name in ["db_get_snapshots", "db_delete_snapshots"]:
|
||||||
|
return handle_snapshots_call(fn_name, fn_args, session_id, user_message, state_data)
|
||||||
|
|
||||||
|
elif fn_name == "db_get_rules":
|
||||||
|
res_str = json.dumps(get_rules(), ensure_ascii=False)
|
||||||
|
elif fn_name == "db_get_stats":
|
||||||
|
res_str = json.dumps(db_get_stats(), ensure_ascii=False)
|
||||||
|
elif fn_name == "db_get_anomalies":
|
||||||
|
res_str = 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_reference":
|
||||||
|
res_str = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False)
|
||||||
|
else:
|
||||||
|
res_str = "{}"
|
||||||
|
|
||||||
|
messages.append(msg)
|
||||||
|
messages.append({"role": "tool", "content": res_str})
|
||||||
|
sec_msg = call_ollama_chat(messages, is_vision=False)
|
||||||
|
final_content = clean_raw_tool_tags(clean_output(sec_msg.get("content", ""))) or "Запрос выполнен."
|
||||||
|
db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=0)
|
||||||
|
return final_content, db_get_chat_history(session_id), None
|
||||||
|
|
||||||
|
# 4. Обычный содержательный диалог и управление Topic Drift
|
||||||
|
final_reply = clean_raw_tool_tags(clean_output(raw_reply)) or "Запрос обработан."
|
||||||
|
for artifact in ["почемучка,", "почемучка!", "почемучка?", "почемучка", "почемучто,", "почемучто", "почему-то"]:
|
||||||
|
if final_reply.lower().startswith(artifact):
|
||||||
|
final_reply = final_reply[len(artifact):].lstrip(",.!?:; -")
|
||||||
|
|
||||||
|
action_payload = None
|
||||||
|
|
||||||
|
# Обработка ответа "нет / спасибо" в режиме открытого инструмента
|
||||||
|
if session_state and any(kw in user_message.lower() for kw in ["нет", "спасибо", "не надо", "готово", "хватит"]):
|
||||||
|
close_tool_session_and_cleanup(session_id, close_reason="USER_DISMISSED_TOOL")
|
||||||
|
db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0)
|
||||||
|
return final_reply, db_get_chat_history(session_id), None
|
||||||
|
|
||||||
|
# Инкремент счётчика шагов в сторону от инструмента (Topic Drift)
|
||||||
|
if session_state and session_state.get("state_type") in ["PROMPT_FOLLOWUP", "PROMPT_PREVIEW", "SNAPSHOTS_VIEW"]:
|
||||||
|
state_type = session_state.get("state_type")
|
||||||
|
state_data = session_state.get("data_json") or {}
|
||||||
|
if not isinstance(state_data, dict):
|
||||||
|
state_data = {}
|
||||||
|
|
||||||
|
idle_turns = state_data.get("idle_turns", 0) + 1
|
||||||
|
state_data["idle_turns"] = idle_turns
|
||||||
|
|
||||||
|
if idle_turns >= 4:
|
||||||
|
# 4-й шаг не по теме: бесшумно закрываем сессию и вычищаем эфемерные карточки
|
||||||
|
close_tool_session_and_cleanup(session_id, close_reason="TOPIC_DRIFT_TIMEOUT")
|
||||||
|
elif idle_turns == 3:
|
||||||
|
# 3-й шаг: выводим вежливое напоминание с кнопками
|
||||||
|
tool_label = "системным промптом" if "PROMPT" in state_type else "снапшотами СКУД"
|
||||||
|
guard_question = f"Желаете продолжить работу с {tool_label}?"
|
||||||
|
final_reply += f"\n\n💡 *Напоминание:* {guard_question}"
|
||||||
|
action_payload = {
|
||||||
|
"type": "FOLLOW_UP_ACTION",
|
||||||
|
"buttons": [
|
||||||
|
{"label": "Показать снова", "value": "покажи системный промпт" if "PROMPT" in state_type else "покажи снапшоты", "style": "primary"},
|
||||||
|
{"label": "Завершить", "value": "нет, спасибо", "style": "secondary"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
db_set_session_state(session_id, state_type, state_data)
|
||||||
|
else:
|
||||||
|
# 1-й и 2-й шаг: фиксируем обновленный счётчик
|
||||||
|
db_set_session_state(session_id, state_type, state_data)
|
||||||
|
|
||||||
|
is_ephem_reply = 1 if "актуальный системный промпт:" in final_reply.lower() else 0
|
||||||
|
db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=is_ephem_reply)
|
||||||
|
return final_reply, db_get_chat_history(session_id), action_payload
|
||||||
|
|
||||||
|
except Exception as ex:
|
||||||
|
logger.exception(f"Ошибка в агенте: {ex}")
|
||||||
|
return f"Внутренняя ошибка сервера: {ex}", db_get_chat_history(session_id), None
|
||||||
@@ -3,35 +3,136 @@
|
|||||||
FILE: modules/web_api/llm/core/tool_injector.py
|
FILE: modules/web_api/llm/core/tool_injector.py
|
||||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||||
MODULE: web_api / llm / core
|
MODULE: web_api / llm / core
|
||||||
ROLE: Базовая санитарная очистка артефактов без эвристик и регулярных выражений.
|
ROLE: Семантический анализ намерений оператора (Intent Classifier) и
|
||||||
|
детерминированная сборка вызовов инструментов при сбоях нативного Function Calling.
|
||||||
|
|
||||||
AI-CONTEXT-ANCHORS:
|
AI-CONTEXT-ANCHORS:
|
||||||
- ANCHOR[CLEAN_RAW_TOOLS]: Очистка строковых тегов.
|
- ANCHOR[INTENT_INJECTOR_MAIN]: Точка входа inject_tools_if_needed.
|
||||||
- ANCHOR[PASS_THROUGH_TOOLS]: Чистый проходной интерфейс инструментов.
|
|
||||||
===============================================================================
|
===============================================================================
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# ANCHOR[CLEAN_RAW_TOOLS]
|
import re
|
||||||
|
import logging
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
|
logger = logging.getLogger("TOOL_INJECTOR")
|
||||||
|
|
||||||
|
|
||||||
def clean_raw_tool_tags(text: str) -> str:
|
def clean_raw_tool_tags(text: str) -> str:
|
||||||
"""Удаляет только технические теги разметки, если они попали в текст."""
|
"""Удаляет сырые теги вызова инструментов и системный шум."""
|
||||||
if not text:
|
if not text:
|
||||||
return ""
|
return ""
|
||||||
return text.replace("<tool_call>", "").replace("</tool_call>", "").strip()
|
cleaned = re.sub(r'<tool_call>.*?</tool_call>', '', text, flags=re.DOTALL)
|
||||||
|
cleaned = re.sub(r'<\|.*?\|>', '', cleaned)
|
||||||
|
return cleaned.strip()
|
||||||
|
|
||||||
|
|
||||||
def clean_output(text: str) -> str:
|
def clean_output(text: str) -> str:
|
||||||
"""Возвращает текст ответа без изменения смысла."""
|
"""Очищает маркеры форматирования."""
|
||||||
if not text:
|
return text.strip() if text else ""
|
||||||
return ""
|
|
||||||
return text.strip()
|
|
||||||
|
|
||||||
|
|
||||||
# ANCHOR[PASS_THROUGH_TOOLS]
|
# ANCHOR[INTENT_INJECTOR_MAIN]
|
||||||
def inject_tools_if_needed(user_message: str, raw_text_content: str, tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
def inject_tools_if_needed(user_message: str, raw_reply: str, existing_tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Чистый сквозной проход: все решения принимает исключительно языковая модель.
|
Гибридный семантический анализатор:
|
||||||
|
Если модель ответила текстом с сомнениями или пропустила 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
|
||||||
+145
-244
@@ -2,263 +2,164 @@
|
|||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>SCUD Orion AI — Context Manager</title>
|
<title>SCUD Orion AI — Управление и Аналитика</title>
|
||||||
|
<!-- Tailwind CSS CDN -->
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<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="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>
|
</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">
|
<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="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="p-4 border-b border-slate-200 flex items-center justify-between bg-slate-50/70">
|
||||||
<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">
|
|
||||||
<div class="flex items-center gap-2">
|
<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">
|
<div class="w-8 h-8 rounded-lg bg-indigo-600 flex items-center justify-center text-white shadow-sm">
|
||||||
<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-list-check text-sm"></i>
|
||||||
<i class="fa-solid fa-paperclip text-lg"></i>
|
</div>
|
||||||
</button>
|
<div>
|
||||||
|
<h2 class="font-bold text-sm text-slate-900 leading-tight">Бэклог задач</h2>
|
||||||
<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">
|
<p class="text-[11px] text-slate-500">SCUD Orion AI Roadmap</p>
|
||||||
<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>
|
</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>
|
</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>
|
||||||
</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>
|
<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>
|
||||||
|
|
||||||
<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">
|
<!-- Список задач с прокруткой (ID исправлен на tasks-list) -->
|
||||||
<div class="p-3.5 border-b border-slate-200 flex justify-between items-center bg-slate-50 shrink-0">
|
<div id="tasks-list" class="flex-1 overflow-y-auto p-3 space-y-2.5">
|
||||||
<h2 class="font-bold text-slate-800 flex items-center gap-2 text-sm">
|
<div class="text-center py-8 text-xs text-slate-400">Загрузка задач...</div>
|
||||||
<i class="fa-solid fa-list-check text-indigo-600"></i> Мой реестр задач
|
</div>
|
||||||
</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="Обновить">
|
<div class="p-3 border-t border-slate-200 bg-slate-50/50 flex items-center justify-between text-xs">
|
||||||
<i class="fa-solid fa-rotate-right text-sm"></i>
|
<div class="flex items-center gap-2">
|
||||||
</button>
|
<div class="w-7 h-7 rounded-full bg-slate-300 flex items-center justify-center text-slate-700 font-bold">
|
||||||
<button type="button" onclick="toggleDrawer()" class="text-slate-500 hover:text-slate-800 transition p-1">
|
<i class="fa-solid fa-user text-xs"></i>
|
||||||
<i class="fa-solid fa-xmark text-lg"></i>
|
</div>
|
||||||
</button>
|
<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>
|
||||||
</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>
|
||||||
<!-- ANCHOR[DRAWER_TAB_RENAMING]: Вкладки фильтрации боковой панели -->
|
</button>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Подключение скриптов интерфейса -->
|
<!-- Основная рабочая область чата -->
|
||||||
<script src="/static/js/auth.js"></script>
|
<main class="flex-1 flex flex-col min-w-0 bg-white relative h-full overflow-hidden">
|
||||||
<script src="/static/js/tasks.js"></script>
|
<!-- Верхний заголовок чата -->
|
||||||
<script src="/static/js/chat/task_widget.js"></script>
|
<header class="h-14 border-b border-slate-200 px-4 flex items-center justify-between bg-white shrink-0 z-10">
|
||||||
<script src="/static/js/chat/core.js"></script>
|
<div class="flex items-center gap-3">
|
||||||
<script src="/static/js/app.js"></script>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -229,13 +229,53 @@ async function saveManualPromptDraft(btnEl) {
|
|||||||
const highlightedHtml = buildHighlightedPromptHtml(newText, baseline);
|
const highlightedHtml = buildHighlightedPromptHtml(newText, baseline);
|
||||||
previewTextEl.innerHTML = highlightedHtml;
|
previewTextEl.innerHTML = highlightedHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let actionButtonsContainer = cardContainer.querySelector(".action-buttons-container");
|
||||||
|
if (actionButtonsContainer) {
|
||||||
|
actionButtonsContainer.innerHTML = `
|
||||||
|
<button type="button" onclick="handleActionButtonClick('подтверждаю')"
|
||||||
|
class="bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white shadow-sm font-semibold px-3 py-1.5 rounded-xl text-xs flex items-center gap-1.5 transition cursor-pointer">
|
||||||
|
<i class="fa-solid fa-check"></i>
|
||||||
|
<span>Подтвердить</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="handleActionButtonClick('отмена')"
|
||||||
|
class="bg-rose-50 hover:bg-rose-100 active:bg-rose-200 text-rose-700 border border-rose-300 font-semibold px-3 py-1.5 rounded-xl text-xs flex items-center gap-1.5 transition cursor-pointer">
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
<span>Отменить</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="openInlinePromptEditor(this)"
|
||||||
|
class="bg-indigo-50 hover:bg-indigo-100 active:bg-indigo-200 text-indigo-700 border border-indigo-300 font-semibold px-3 py-1.5 rounded-xl text-xs flex items-center gap-1.5 transition cursor-pointer">
|
||||||
|
<i class="fa-solid fa-pen-to-square text-xs"></i>
|
||||||
|
<span>✏️ Редактировать</span>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert("Ошибка сохранения черновика: " + err);
|
alert("Ошибка сохранения черновика: " + err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- [CHAT SEND PIPELINE] ---
|
// ⭐️ Абсолютный расчет позиции скролла к верху видимой области
|
||||||
|
function scrollToTurnTop(anchorId) {
|
||||||
|
const chatWindow = document.getElementById("chat-window");
|
||||||
|
const anchorEl = document.getElementById(anchorId);
|
||||||
|
if (!chatWindow || !anchorEl) return;
|
||||||
|
|
||||||
|
const windowRect = chatWindow.getBoundingClientRect();
|
||||||
|
const anchorRect = anchorEl.getBoundingClientRect();
|
||||||
|
const currentScroll = chatWindow.scrollTop;
|
||||||
|
|
||||||
|
// Новая позиция скролла = текущий сдвиг + расстояние от верхней кромки окна до элемента
|
||||||
|
const targetTop = currentScroll + (anchorRect.top - windowRect.top) - 12;
|
||||||
|
|
||||||
|
chatWindow.scrollTo({
|
||||||
|
top: Math.max(0, targetTop),
|
||||||
|
behavior: "smooth"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- [CHAT SEND PIPELINE (GEMINI STYLE TOP-SCROLL)] ---
|
||||||
async function sendMessage(e) {
|
async function sendMessage(e) {
|
||||||
if (e && e.preventDefault) e.preventDefault();
|
if (e && e.preventDefault) e.preventDefault();
|
||||||
|
|
||||||
@@ -257,18 +297,29 @@ async function sendMessage(e) {
|
|||||||
</div>` + userDisplayHtml;
|
</div>` + userDisplayHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const turnId = "turn-" + Date.now();
|
||||||
|
|
||||||
const userMsgHtml = `
|
const userMsgHtml = `
|
||||||
<div class="flex justify-end mb-3">
|
<div id="${turnId}" class="chat-turn-anchor flex justify-end mb-3 pt-2 scroll-mt-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">
|
<div class="bg-indigo-600 text-white rounded-2xl px-4 py-2.5 max-w-xl text-xs sm:text-sm shadow-sm leading-relaxed">
|
||||||
${userDisplayHtml}
|
${userDisplayHtml}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
chatWindow.insertAdjacentHTML("beforeend", userMsgHtml);
|
|
||||||
|
// Вставляем вопрос строго перед невидимой распоркой
|
||||||
|
const spacer = document.getElementById("chat-bottom-spacer");
|
||||||
|
if (spacer) {
|
||||||
|
spacer.insertAdjacentHTML("beforebegin", userMsgHtml);
|
||||||
|
} else {
|
||||||
|
chatWindow.insertAdjacentHTML("beforeend", userMsgHtml);
|
||||||
|
}
|
||||||
|
|
||||||
input.value = "";
|
input.value = "";
|
||||||
updateInputHeight(input);
|
updateInputHeight(input);
|
||||||
chatWindow.scrollTop = chatWindow.scrollHeight;
|
|
||||||
|
// Скроллим сразу при отправке вопроса
|
||||||
|
setTimeout(() => scrollToTurnTop(turnId), 50);
|
||||||
|
|
||||||
if (sendBtn) {
|
if (sendBtn) {
|
||||||
sendBtn.disabled = true;
|
sendBtn.disabled = true;
|
||||||
@@ -384,7 +435,6 @@ async function sendMessage(e) {
|
|||||||
snapshotsWidgetHtml = renderSnapshotsCard(actionData.data);
|
snapshotsWidgetHtml = renderSnapshotsCard(actionData.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⭐️ БЛОК КАРТОЧКИ СКАЧИВАНИЯ ФАЙЛА
|
|
||||||
let fileDownloadHtml = "";
|
let fileDownloadHtml = "";
|
||||||
if (actionData && actionData.type === "FILE_DOWNLOAD_CARD") {
|
if (actionData && actionData.type === "FILE_DOWNLOAD_CARD") {
|
||||||
fileDownloadHtml = `
|
fileDownloadHtml = `
|
||||||
@@ -409,20 +459,28 @@ async function sendMessage(e) {
|
|||||||
const maxWidthClass = isWideWidget ? "max-w-4xl w-full" : "max-w-2xl";
|
const maxWidthClass = isWideWidget ? "max-w-4xl w-full" : "max-w-2xl";
|
||||||
|
|
||||||
const botMsgHtml = `
|
const botMsgHtml = `
|
||||||
<div class="chat-message-card bg-white border border-slate-200 rounded-2xl p-4 shadow-sm ${maxWidthClass} mb-3" data-original-baseline="${escapeHtml(baselineText)}">
|
<div class="chat-message-card bg-white border border-slate-200 rounded-2xl p-4 shadow-sm w-full mb-3" data-original-baseline="${escapeHtml(baselineText)}">
|
||||||
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
|
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
|
||||||
<i class="fa-solid fa-robot mr-1"></i> ${assistantTitle}
|
<i class="fa-solid fa-robot mr-1"></i> ${assistantTitle}
|
||||||
</p>
|
</p>
|
||||||
<div class="prompt-preview-diff-view text-slate-800 text-xs sm:text-sm whitespace-pre-wrap leading-relaxed">${replyText}</div>
|
<div class="prompt-preview-diff-view text-slate-800 text-xs sm:text-sm whitespace-pre-wrap leading-relaxed">${replyText}</div>
|
||||||
${previewEditorHtml}
|
${previewEditorHtml}
|
||||||
${interactiveWidgetHtml}
|
${interactiveWidgetHtml}
|
||||||
${snapshotsWidgetHtml}
|
${snapshotsWidgetHtml}
|
||||||
${fileDownloadHtml}
|
${fileDownloadHtml}
|
||||||
${actionButtonsHtml}
|
${actionButtonsHtml}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
chatWindow.insertAdjacentHTML("beforeend", botMsgHtml);
|
|
||||||
chatWindow.scrollTop = chatWindow.scrollHeight;
|
// Вставляем карточку ответа строго перед невидимой распоркой
|
||||||
|
if (spacer) {
|
||||||
|
spacer.insertAdjacentHTML("beforebegin", botMsgHtml);
|
||||||
|
} else {
|
||||||
|
chatWindow.insertAdjacentHTML("beforeend", botMsgHtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Скроллим к началу хода после рендера карточки ответа
|
||||||
|
setTimeout(() => scrollToTurnTop(turnId), 100);
|
||||||
|
|
||||||
clearAttachedFile();
|
clearAttachedFile();
|
||||||
|
|
||||||
@@ -437,8 +495,12 @@ async function sendMessage(e) {
|
|||||||
Ошибка связи с сервером.
|
Ошибка связи с сервером.
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
chatWindow.insertAdjacentHTML("beforeend", errorHtml);
|
if (spacer) {
|
||||||
chatWindow.scrollTop = chatWindow.scrollHeight;
|
spacer.insertAdjacentHTML("beforebegin", errorHtml);
|
||||||
|
} else {
|
||||||
|
chatWindow.insertAdjacentHTML("beforeend", errorHtml);
|
||||||
|
}
|
||||||
|
setTimeout(() => scrollToTurnTop(turnId), 50);
|
||||||
setInputLocked(false);
|
setInputLocked(false);
|
||||||
} finally {
|
} finally {
|
||||||
const inputEl = document.getElementById("user-input");
|
const inputEl = document.getElementById("user-input");
|
||||||
|
|||||||
@@ -1,164 +1,117 @@
|
|||||||
/**
|
/**
|
||||||
===============================================================================
|
===============================================================================
|
||||||
FILE: modules/web_api/static/js/tasks.js
|
FILE: modules/web_api/static/js/tasks.js
|
||||||
ROLE: Управление боковой панелью задач (Drawer), фильтрация и рендеринг карточек.
|
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||||
|
MODULE: web_api / static / js
|
||||||
AI-CONTEXT-ANCHORS:
|
ROLE: Загрузка, фильтрация и рендеринг списка задач в боковой панели (Drawer).
|
||||||
- ANCHOR[DRAWER_TOGGLE]: Открытие и закрытие выезжающей панели.
|
|
||||||
- ANCHOR[TASKS_FILTER]: Фильтрация списка (ALL, IN_PROGRESS, BACKLOG, COMPLETED).
|
|
||||||
- ANCHOR[TASKS_LOAD_FETCH]: Асинхронная загрузка задач через /api/v1/tasks.
|
|
||||||
- ANCHOR[TASKS_RENDER_DOM]: Генерация HTML-карточек в боковом меню.
|
|
||||||
===============================================================================
|
===============================================================================
|
||||||
*/
|
*/
|
||||||
|
|
||||||
let currentFilter = 'ALL';
|
let currentTaskFilter = 'IN_PROGRESS';
|
||||||
let allTasks = [];
|
|
||||||
|
|
||||||
// --- [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() {
|
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 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 (!token) return;
|
||||||
|
|
||||||
if (isGuest || !token) {
|
|
||||||
if (badge) badge.innerText = "0";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/v1/tasks", {
|
const res = await fetch("/api/v1/tasks", {
|
||||||
headers: {
|
headers: { "Authorization": "Bearer " + token }
|
||||||
"Authorization": "Bearer " + token,
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
if (res.ok) {
|
||||||
if (res.status === 401) {
|
const data = await res.json();
|
||||||
if (typeof logout === 'function') logout();
|
renderSidebarTasks(data.tasks || []);
|
||||||
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)) || [];
|
|
||||||
} else {
|
} else {
|
||||||
allTasks = [];
|
renderSidebarError("Ошибка доступа. Авторизуйтесь снова.");
|
||||||
}
|
|
||||||
|
|
||||||
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>`;
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Ошибка загрузки задач:", e);
|
||||||
|
renderSidebarError("Ошибка сети. Сервер недоступен.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- [SECTION 4: DOM RENDERING] --- # ANCHOR[TASKS_RENDER_DOM]
|
function renderSidebarError(msg) {
|
||||||
function renderTasks() {
|
// Поддерживаем оба варианта ID (новый и старый) для обратной совместимости
|
||||||
const container = document.getElementById("tasks-container");
|
const container = document.getElementById("tasks-list") || document.getElementById("tasks-list-container");
|
||||||
if (!container) return;
|
if (container) {
|
||||||
|
container.innerHTML = `<div class="text-center py-8 text-xs text-rose-500 font-semibold">${msg}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!Array.isArray(allTasks)) {
|
function filterTasksByTab(status) {
|
||||||
allTasks = [];
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = allTasks.filter(t => currentFilter === "ALL" || t.status === currentFilter);
|
loadTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSidebarTasks(tasks) {
|
||||||
|
const container = document.getElementById("tasks-list") || document.getElementById("tasks-list-container");
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
let filtered = tasks;
|
||||||
|
if (currentTaskFilter !== 'ALL') {
|
||||||
|
filtered = tasks.filter(t => t.status === currentTaskFilter);
|
||||||
|
}
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
container.innerHTML = `<div class="text-slate-400 text-xs py-8 text-center">Нет задач с выбранным фильтром</div>`;
|
container.innerHTML = `<div class="text-center py-8 text-[11px] font-medium text-slate-400 bg-slate-50 rounded-xl border border-dashed border-slate-200">Нет задач в этой категории</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = filtered.map(t => {
|
container.innerHTML = filtered.map(t => {
|
||||||
let statusBadge = "bg-slate-100 text-slate-600 border-slate-200";
|
const isCompleted = t.status === 'COMPLETED';
|
||||||
let statusLabel = "В планах";
|
const priorityColor = t.priority === 'HIGH' || t.priority === 'CRITICAL'
|
||||||
let cardBg = "bg-white";
|
? 'text-rose-600 bg-rose-50 border-rose-200'
|
||||||
|
: t.priority === 'MEDIUM'
|
||||||
if (t.status === "COMPLETED") {
|
? 'text-amber-600 bg-amber-50 border-amber-200'
|
||||||
statusBadge = "bg-emerald-50 text-emerald-700 border-emerald-300 font-semibold";
|
: 'text-slate-600 bg-slate-50 border-slate-200';
|
||||||
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 `
|
return `
|
||||||
<div class="${cardBg} border border-slate-200 rounded-xl p-3.5 shadow-sm hover:shadow-md transition">
|
<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"
|
||||||
<div class="flex justify-between items-center mb-1.5">
|
onclick="handleActionButtonClick('покажи задачу ${t.id}')">
|
||||||
<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>
|
<div class="flex items-start justify-between gap-2">
|
||||||
<span class="text-[10px] uppercase px-1.5 py-0.5 rounded border ${priorityBadge}">${t.priority || 'MEDIUM'}</span>
|
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-wider group-hover:text-indigo-500 transition">#${t.id}</span>
|
||||||
</div>
|
${isCompleted
|
||||||
<span class="text-[10px] uppercase px-2 py-0.5 rounded border ${statusBadge}">${statusLabel}</span>
|
? `<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>
|
</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">
|
<div class="text-xs font-semibold text-slate-700 leading-snug line-clamp-3">${escapeHtml(t.title || 'Без названия')}</div>
|
||||||
<i class="fa-solid fa-folder-closed text-slate-300"></i>
|
|
||||||
<span>${t.module || 'General'}</span>
|
<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>
|
</div>
|
||||||
${dueDateHtml}
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join("");
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Глобальная инициализация при загрузке DOM
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
// Небольшая задержка, чтобы гарантировать применение токена
|
||||||
|
setTimeout(loadTasks, 200);
|
||||||
|
});
|
||||||
@@ -10,16 +10,24 @@ AI-CONTEXT-ANCHORS:
|
|||||||
===============================================================================
|
===============================================================================
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Tuple, Dict
|
from typing import Tuple, Dict, List, Set
|
||||||
from core.connection import get_connection
|
from core.connection import get_connection
|
||||||
|
|
||||||
|
def build_prompt_diff(
|
||||||
# ANCHOR[PROMPT_DIFF_BUILDER]
|
action: str,
|
||||||
def build_prompt_diff(action: str, section_id: int, item_id: int, content: str = "", prompt_name: str = "main_agent") -> Tuple[str, str]:
|
section_id: int = None,
|
||||||
"""
|
item_id: int = None,
|
||||||
Возвращает кортеж (merged_draft_text, diff_html_for_ui).
|
content: str = "",
|
||||||
"""
|
delete_nodes: List[Tuple[int, int]] = None,
|
||||||
|
prompt_name: str = "main_agent"
|
||||||
|
) -> Tuple[str, str]:
|
||||||
act = (action or "ADD").upper()
|
act = (action or "ADD").upper()
|
||||||
|
nodes_to_delete: Set[Tuple[int, int]] = set()
|
||||||
|
|
||||||
|
if delete_nodes:
|
||||||
|
nodes_to_delete.update(delete_nodes)
|
||||||
|
elif act == "DELETE" and section_id is not None and item_id is not None:
|
||||||
|
nodes_to_delete.add((section_id, item_id))
|
||||||
|
|
||||||
with get_connection(row_factory=True) as conn:
|
with get_connection(row_factory=True) as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
@@ -32,46 +40,40 @@ def build_prompt_diff(action: str, section_id: int, item_id: int, content: str =
|
|||||||
existing_nodes = cursor.fetchall()
|
existing_nodes = cursor.fetchall()
|
||||||
|
|
||||||
nodes_dict = {(r["section_id"], r["item_id"]): r["content"] for r in existing_nodes}
|
nodes_dict = {(r["section_id"], r["item_id"]): r["content"] for r in existing_nodes}
|
||||||
|
nodes_dict_for_draft = {k: v for k, v in nodes_dict.items() if k not in nodes_to_delete}
|
||||||
|
|
||||||
# Формируем словарь для чистого текста
|
if act not in ["DELETE", "BATCH_DELETE"] and section_id is not None and item_id is not None:
|
||||||
nodes_dict_for_draft = {k: v for k, v in nodes_dict.items() if k != (section_id, item_id)} if act == "DELETE" else dict(nodes_dict)
|
|
||||||
if act != "DELETE":
|
|
||||||
nodes_dict_for_draft[(section_id, item_id)] = content
|
nodes_dict_for_draft[(section_id, item_id)] = content
|
||||||
|
|
||||||
draft_lines = []
|
draft_lines = []
|
||||||
curr_sec = None
|
curr_sec = None
|
||||||
for (s_id, i_id), txt in sorted(nodes_dict_for_draft.items()):
|
for (s_id, i_id), txt in sorted(nodes_dict_for_draft.items()):
|
||||||
if i_id == 0:
|
if i_id == 0:
|
||||||
if curr_sec is not None:
|
if curr_sec is not None: draft_lines.append("")
|
||||||
draft_lines.append("")
|
|
||||||
draft_lines.append(f"{s_id}. {txt}")
|
draft_lines.append(f"{s_id}. {txt}")
|
||||||
curr_sec = s_id
|
curr_sec = s_id
|
||||||
else:
|
else:
|
||||||
draft_lines.append(f" {s_id}.{i_id}. {txt}")
|
draft_lines.append(f" {s_id}.{i_id}. {txt}")
|
||||||
merged_prompt = "\n".join(draft_lines)
|
merged_prompt = "\n".join(draft_lines)
|
||||||
|
|
||||||
# Формируем HTML Diff
|
|
||||||
diff_lines = []
|
diff_lines = []
|
||||||
curr_sec = None
|
curr_sec = None
|
||||||
display_nodes = dict(nodes_dict)
|
display_nodes = dict(nodes_dict)
|
||||||
if act != "DELETE":
|
if act not in ["DELETE", "BATCH_DELETE"] and section_id is not None and item_id is not None:
|
||||||
display_nodes[(section_id, item_id)] = content
|
display_nodes[(section_id, item_id)] = content
|
||||||
|
|
||||||
for (s_id, i_id), txt in sorted(display_nodes.items()):
|
for (s_id, i_id), txt in sorted(display_nodes.items()):
|
||||||
if i_id == 0:
|
if i_id == 0:
|
||||||
if curr_sec is not None:
|
if curr_sec is not None: diff_lines.append("")
|
||||||
diff_lines.append("")
|
|
||||||
diff_lines.append(f"{s_id}. {txt}")
|
diff_lines.append(f"{s_id}. {txt}")
|
||||||
curr_sec = s_id
|
curr_sec = s_id
|
||||||
else:
|
else:
|
||||||
if s_id == section_id and i_id == item_id:
|
if (s_id, i_id) in nodes_to_delete:
|
||||||
if act == "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>'
|
||||||
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>'
|
elif act == "ADD" and s_id == section_id and i_id == item_id:
|
||||||
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>'
|
||||||
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:
|
else:
|
||||||
line_str = f" {s_id}.{i_id}. {txt}"
|
line_str = f" {s_id}.{i_id}. {txt}"
|
||||||
diff_lines.append(line_str)
|
diff_lines.append(line_str)
|
||||||
|
|
||||||
diff_html = "\n".join(diff_lines)
|
return merged_prompt, "\n".join(diff_lines)
|
||||||
return merged_prompt, diff_html
|
|
||||||
@@ -7,7 +7,7 @@ ROLE: Единый доменный сервис управления систе
|
|||||||
===============================================================================
|
===============================================================================
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Tuple, Dict, Any
|
from typing import Tuple, Dict, Any, List, Optional
|
||||||
from .repository import repo_get_active_prompt, repo_apply_prompt_action, repo_save_full_prompt
|
from .repository import repo_get_active_prompt, repo_apply_prompt_action, repo_save_full_prompt
|
||||||
from .diff_engine import build_prompt_diff
|
from .diff_engine import build_prompt_diff
|
||||||
|
|
||||||
@@ -22,16 +22,28 @@ def apply_prompt_action(action: str, section_id: int, item_id: int, content: str
|
|||||||
repo_apply_prompt_action(action, section_id, item_id, content)
|
repo_apply_prompt_action(action, section_id, item_id, content)
|
||||||
|
|
||||||
|
|
||||||
def save_full_prompt_draft(draft_text: str) -> None:
|
def save_full_prompt_draft(draft_text: str, prompt_name: str = "main_agent") -> None:
|
||||||
"""Сохранить полный черновик промпта."""
|
"""Сохранить полный черновик промпта."""
|
||||||
repo_save_full_prompt(draft_text)
|
repo_save_full_prompt(draft_text, prompt_name=prompt_name)
|
||||||
|
|
||||||
|
|
||||||
def create_prompt_preview(action: str, section_id: int, item_id: int, content: str = "") -> Tuple[str, str, str]:
|
def create_prompt_preview(
|
||||||
|
action: str,
|
||||||
|
section_id: Optional[int] = None,
|
||||||
|
item_id: Optional[int] = None,
|
||||||
|
content: str = "",
|
||||||
|
delete_nodes: Optional[List[Tuple[int, int]]] = None
|
||||||
|
) -> Tuple[str, str, str]:
|
||||||
"""
|
"""
|
||||||
Формирует черновик и diff.
|
Формирует черновик и diff.
|
||||||
Возвращает (merged_draft, diff_html, baseline_prompt).
|
Возвращает (merged_draft, diff_html, baseline_prompt).
|
||||||
"""
|
"""
|
||||||
baseline = repo_get_active_prompt()
|
baseline = repo_get_active_prompt()
|
||||||
draft, diff_html = build_prompt_diff(action, section_id, item_id, content)
|
draft, diff_html = build_prompt_diff(
|
||||||
|
action=action,
|
||||||
|
section_id=section_id,
|
||||||
|
item_id=item_id,
|
||||||
|
content=content,
|
||||||
|
delete_nodes=delete_nodes
|
||||||
|
)
|
||||||
return draft, diff_html, baseline
|
return draft, diff_html, baseline
|
||||||
Reference in New Issue
Block a user