refactor(web_api): fix import paths, modularize routers and decompose agent pipeline
This commit is contained in:
+169
-355
@@ -2,25 +2,17 @@
|
|||||||
===============================================================================
|
===============================================================================
|
||||||
FILE: modules/web_api/llm/agent.py
|
FILE: modules/web_api/llm/agent.py
|
||||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||||
MODULE: web_api / llm (Core Agent & Function Calling Dispatcher)
|
MODULE: web_api / llm (Core Agent Coordinator)
|
||||||
ROLE: Главный оркестратор взаимодействия с Ollama LLM (Qwen 2.5), разбор вызовов
|
ROLE: Оркестратор диалога, диспетчер Function Calling и Topic Drift контроллер.
|
||||||
инструментов (Function Calling), интеграция с декларативным реестром
|
|
||||||
действий SQLite (tool_action_registry), детерминированный Fast-Path
|
|
||||||
для подтверждений, отслеживание Topic Drift и Context Guard с кнопками.
|
|
||||||
===============================================================================
|
===============================================================================
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# --- [SECTION 1: SYSTEM PATHS & IMPORTS] --- # ANCHOR[SYS_IMPORTS]
|
# --- [SECTION 1: SYSTEM PATHS & IMPORTS] --- # ANCHOR[SYS_IMPORTS]
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
import urllib.request
|
|
||||||
import urllib.error
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
from typing import List, Dict, Any, Tuple, Optional
|
from typing import List, Dict, Any, Tuple, Optional
|
||||||
|
|
||||||
# Импорт фасада базы данных и прямого подключения
|
|
||||||
from .db.connection import get_db_connection
|
from .db.connection import get_db_connection
|
||||||
from .db_tools import (
|
from .db_tools import (
|
||||||
db_get_active_system_prompt,
|
db_get_active_system_prompt,
|
||||||
@@ -48,8 +40,11 @@ from .db_tools import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from .schemas import TOOLS_SCHEMA
|
from .schemas import TOOLS_SCHEMA
|
||||||
from .core.calendar_utils import get_dynamic_calendar_context, parse_relative_date_ru
|
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.tool_injector import clean_raw_tool_tags, clean_output
|
||||||
|
from .core.ollama_client import call_ollama_chat
|
||||||
|
from .core.fast_path import handle_fast_path_intercept
|
||||||
|
from .core.prompt_merger import build_prompt_preview_merge
|
||||||
|
|
||||||
# --- [SECTION 2: LOGGING CONFIGURATION] --- # ANCHOR[LOGGING_CONFIG]
|
# --- [SECTION 2: LOGGING CONFIGURATION] --- # ANCHOR[LOGGING_CONFIG]
|
||||||
logger = logging.getLogger("SCUD_AGENT")
|
logger = logging.getLogger("SCUD_AGENT")
|
||||||
@@ -62,10 +57,6 @@ if not logger.handlers:
|
|||||||
handler.setFormatter(formatter)
|
handler.setFormatter(formatter)
|
||||||
logger.addHandler(handler)
|
logger.addHandler(handler)
|
||||||
|
|
||||||
OLLAMA_URL = "http://192.168.11.3:11434/api/chat"
|
|
||||||
TEXT_MODEL = "qwen2.5:14b"
|
|
||||||
VISION_MODEL = "qwen2.5vl:7b-q8_0"
|
|
||||||
|
|
||||||
|
|
||||||
# --- [SECTION 3: MAIN CHAT PROCESSING PIPELINE] --- # ANCHOR[CHAT_PROCESSOR]
|
# --- [SECTION 3: MAIN CHAT PROCESSING PIPELINE] --- # ANCHOR[CHAT_PROCESSOR]
|
||||||
def process_chat_message(
|
def process_chat_message(
|
||||||
@@ -77,100 +68,38 @@ def process_chat_message(
|
|||||||
session_id: str = "web_session_main"
|
session_id: str = "web_session_main"
|
||||||
) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||||
"""
|
"""
|
||||||
Главный конвейер обработки входящего сообщения:
|
Главный конвейер диалога:
|
||||||
1. Fast-Path перехват подтверждений/отмен при активном session_state.
|
1. Проверка Fast-Path команд (подтверждение, отмена, завершение).
|
||||||
2. Перехват завершения работы ('нет, закончить настройку') с автоочисткой эфемерных сообщений.
|
2. Формирование контекста и вызов Ollama LLM.
|
||||||
3. Формирование системного контекста с учетом активного действия (черновика).
|
3. Выполнение инструментов и обработка Context Guard.
|
||||||
4. Выполнение вызванного Tool и опрос Data-Driven реестра действий.
|
|
||||||
5. Проверка счетчика отвлечений (idle_turns), Context Guard на 3-м шаге и автоочистка при N > 3.
|
|
||||||
6. Возврат кортежа: (reply_text, chat_history, action_metadata).
|
|
||||||
"""
|
"""
|
||||||
logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}")
|
logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}")
|
||||||
|
|
||||||
# 3.1. Обогащение текста вложением (при наличии)
|
full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message
|
||||||
full_user_content = user_message
|
|
||||||
if file_context:
|
|
||||||
full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}"
|
|
||||||
|
|
||||||
session_state = db_get_session_state(session_id)
|
session_state = db_get_session_state(session_id)
|
||||||
user_msg_clean = user_message.lower().strip(" .!?:;")
|
|
||||||
|
|
||||||
# --- [FAST-PATH 1: ПЕРЕХВАТ ЗАВЕРШЕНИЯ НАСТРОЙКИ С ОЧИСТКОЙ ЭФЕМЕРНОЙ ПАМЯТИ] ---
|
# 1. Fast-Path перехват
|
||||||
if user_msg_clean in ["нет, спасибо", "нет, закончить настройку", "закончить настройку", "завершить", "нет"]:
|
fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state)
|
||||||
db_clear_session_state(session_id)
|
if fast_path_res:
|
||||||
deleted_count = db_purge_ephemeral_messages(session_id)
|
return fast_path_res
|
||||||
logger.info(f"Завершена работа с инструментом. Удалено эфемерных сообщений: {deleted_count}")
|
|
||||||
|
|
||||||
reply_text = "Хорошо. Настройка завершена, контекст диалога чист. Чем я могу помочь дальше?"
|
# 2. Сохранение пользовательского сообщения
|
||||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0)
|
|
||||||
return reply_text, db_get_chat_history(session_id), None
|
|
||||||
|
|
||||||
# --- [FAST-PATH 2: ПЕРЕХВАТ ПОДТВЕРЖДЕНИЯ / ОТМЕНЫ ПРЕВЬЮ ПРОМПТА] ---
|
|
||||||
if session_state and session_state.get("state_type") == "PROMPT_PREVIEW":
|
|
||||||
draft_text = ""
|
|
||||||
if session_state.get("data_json") and isinstance(session_state["data_json"], dict):
|
|
||||||
draft_text = session_state["data_json"].get("draft_text", "")
|
|
||||||
else:
|
|
||||||
draft_text = session_state.get("pending_data", "")
|
|
||||||
|
|
||||||
if user_msg_clean in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]:
|
|
||||||
db_add_system_prompt("main_agent", draft_text)
|
|
||||||
|
|
||||||
# Переводим сессию в режим follow-up диалога
|
|
||||||
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0})
|
|
||||||
|
|
||||||
action_cfg = db_get_tool_action("db_confirm_prompt_preview")
|
|
||||||
reply_text = action_cfg["success_template"] if action_cfg else "Системный промпт успешно сохранен и применен в базе данных."
|
|
||||||
if action_cfg and action_cfg.get("follow_up_question"):
|
|
||||||
reply_text += f"\n\n{action_cfg['follow_up_question']}"
|
|
||||||
|
|
||||||
reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip()
|
|
||||||
|
|
||||||
is_eph = action_cfg.get("is_ephemeral", 1) if action_cfg else 1
|
|
||||||
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1)
|
|
||||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph)
|
|
||||||
return reply_text, db_get_chat_history(session_id), {
|
|
||||||
"type": action_cfg.get("action_type") if action_cfg else "FOLLOW_UP_ACTION",
|
|
||||||
"buttons": action_cfg.get("buttons", []) if action_cfg else []
|
|
||||||
}
|
|
||||||
|
|
||||||
elif user_msg_clean in ["отмена", "отменить", "отклонить", "назад", "стоп"]:
|
|
||||||
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0})
|
|
||||||
|
|
||||||
action_cfg = db_get_tool_action("db_cancel_prompt_preview")
|
|
||||||
reply_text = action_cfg["success_template"] if action_cfg else "Изменения системного промпта отменены."
|
|
||||||
if action_cfg and action_cfg.get("follow_up_question"):
|
|
||||||
reply_text += f"\n\n{action_cfg['follow_up_question']}"
|
|
||||||
|
|
||||||
reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip()
|
|
||||||
|
|
||||||
is_eph = action_cfg.get("is_ephemeral", 1) if action_cfg else 1
|
|
||||||
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1)
|
|
||||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph)
|
|
||||||
return reply_text, db_get_chat_history(session_id), {
|
|
||||||
"type": action_cfg.get("action_type") if action_cfg else "FOLLOW_UP_ACTION",
|
|
||||||
"buttons": action_cfg.get("buttons", []) if action_cfg else []
|
|
||||||
}
|
|
||||||
|
|
||||||
# 3.2. Сохраняем входящее сообщение в историю диалога
|
|
||||||
is_user_ephemeral = 1 if session_state else 0
|
is_user_ephemeral = 1 if session_state else 0
|
||||||
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=is_user_ephemeral)
|
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=is_user_ephemeral)
|
||||||
|
|
||||||
# 3.3. Извлекаем полную актуальную историю для передачи в LLM
|
|
||||||
db_history = db_get_chat_history(session_id, limit=20)
|
db_history = db_get_chat_history(session_id, limit=20)
|
||||||
|
|
||||||
|
# 3. Сборка системного промпта
|
||||||
dynamic_prompt_text = db_get_active_system_prompt()
|
dynamic_prompt_text = db_get_active_system_prompt()
|
||||||
calendar_context = get_dynamic_calendar_context()
|
calendar_context = get_dynamic_calendar_context()
|
||||||
user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость"
|
user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость"
|
||||||
|
|
||||||
# Информируем модель о наличии незавершенного действия в сессии
|
|
||||||
active_state_context = ""
|
active_state_context = ""
|
||||||
if session_state and session_state.get("state_type") in ["PROMPT_PREVIEW", "PROMPT_FOLLOWUP"]:
|
if session_state and session_state.get("state_type") in ["PROMPT_PREVIEW", "PROMPT_FOLLOWUP"]:
|
||||||
active_state_context = (
|
active_state_context = (
|
||||||
"\n[АКТИВНОЕ ДЕЙСТВИЕ В СЕССИИ]\n"
|
"\n[АКТИВНОЕ ДЕЙСТВИЕ В СЕССИИ]\n"
|
||||||
"В данный момент оператор находится в процессе настройки системного промпта.\n"
|
"В данный момент оператор находится в процессе настройки системного промпта.\n"
|
||||||
"- Если оператор просит продолжить правки или уточняет детали — продолжай работу с ним.\n"
|
"- Если оператор просит продолжить правки — продолжай работу с ним.\n"
|
||||||
"- Если оператор переключился на другую тему или вызвал другой инструмент — выполни его команду штатно.\n"
|
"- Если оператор переключился на другую тему — выполни его команду штатно.\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
system_prompt_content = (
|
system_prompt_content = (
|
||||||
@@ -181,307 +110,165 @@ def process_chat_message(
|
|||||||
f"- {calendar_context}\n"
|
f"- {calendar_context}\n"
|
||||||
f"{active_state_context}\n"
|
f"{active_state_context}\n"
|
||||||
f"[ПРАВИЛА ИСПОЛЬЗОВАНИЯ ИНСТРУМЕНТОВ]\n"
|
f"[ПРАВИЛА ИСПОЛЬЗОВАНИЯ ИНСТРУМЕНТОВ]\n"
|
||||||
f"1. Для любых изменений системного промпта (добавить, удалить, изменить пункт) ВСЕГДА вызывай функцию db_preview_prompt_merge(prompt_text=...).\n"
|
f"1. Для любых изменений системного промпта ВСЕГДА вызывай db_preview_prompt_merge(prompt_text=...).\n"
|
||||||
f"2. Для просмотра системного промпта ВСЕГДА вызывай db_get_system_prompt().\n"
|
f"2. Для просмотра системного промпта ВСЕГДА вызывай db_get_system_prompt().\n"
|
||||||
f"3. Для просмотра задач ВСЕГДА вызывай db_get_tasks().\n"
|
f"3. Для просмотра задач ВСЕГДА вызывай db_get_tasks().\n"
|
||||||
f"4. Никогда не симулируй выполнение функций в виде обычного текста. Если требуется действие — сразу вызывай соответствующий инструмент.\n\n"
|
f"4. Не симулируй выполнение функций текстом — сразу вызывай инструмент.\n\n"
|
||||||
f"[ПРИМЕРЫ ВЫЗОВА ИНСТРУМЕНТОВ]:\n"
|
|
||||||
f"- Пользователь: 'добавь пункт 3.4. Работать от сюда и до заката.' -> Вызов: db_preview_prompt_merge(prompt_text='3.4. Работать от сюда и до заката.')\n"
|
|
||||||
f"- Пользователь: 'удали пункт 3.4' -> Вызов: db_preview_prompt_merge(prompt_text='3.4')\n"
|
|
||||||
f"- Пользователь: 'покажи системный промпт' -> Вызов: db_get_system_prompt()\n"
|
|
||||||
f"- Пользователь: 'покажи мои задачи' -> Вызов: db_get_tasks()\n\n"
|
|
||||||
f"[ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ]:\n{dynamic_prompt_text}"
|
f"[ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ]:\n{dynamic_prompt_text}"
|
||||||
)
|
)
|
||||||
|
|
||||||
llm_options = {
|
|
||||||
"num_predict": 8192,
|
|
||||||
"num_ctx": 8192,
|
|
||||||
"temperature": 0.1,
|
|
||||||
"repeat_penalty": 1.1,
|
|
||||||
"presence_penalty": 0.5,
|
|
||||||
"top_p": 0.9
|
|
||||||
}
|
|
||||||
|
|
||||||
user_msg_object = {"role": "user", "content": full_user_content}
|
user_msg_object = {"role": "user", "content": full_user_content}
|
||||||
|
|
||||||
# --- [SECTION 4: ROUTING & OLLAMA PAYLOAD] --- # ANCHOR[PAYLOAD_BUILD]
|
# --- [SECTION 4: OLLAMA INFERENCE & DISPATCH] --- # ANCHOR[TOOL_ROUTER]
|
||||||
if image_b64:
|
|
||||||
user_msg_object["images"] = [image_b64]
|
|
||||||
messages = [
|
|
||||||
{"role": "system", "content": "Ты — строгий модуль OCR. Перепиши весь текст с изображения буква в букву без отсебятины."},
|
|
||||||
user_msg_object
|
|
||||||
]
|
|
||||||
payload = {"model": VISION_MODEL, "messages": messages, "stream": False, "options": llm_options}
|
|
||||||
else:
|
|
||||||
clean_db_history = [dict(m) for m in db_history]
|
|
||||||
for m in clean_db_history:
|
|
||||||
m.pop("images", None)
|
|
||||||
messages = [{"role": "system", "content": system_prompt_content}] + clean_db_history + [user_msg_object]
|
|
||||||
payload = {"model": TEXT_MODEL, "messages": messages, "tools": TOOLS_SCHEMA, "stream": False, "options": llm_options}
|
|
||||||
|
|
||||||
# --- [SECTION 5: EXECUTION & TOOL ROUTING] --- # ANCHOR[TOOL_ROUTER]
|
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(
|
if image_b64:
|
||||||
OLLAMA_URL,
|
user_msg_object["images"] = [image_b64]
|
||||||
data=json.dumps(payload).encode("utf-8"),
|
messages = [
|
||||||
headers={"Content-Type": "application/json"}
|
{"role": "system", "content": "Ты — строгий модуль OCR. Перепиши весь текст с изображения буква в букву."},
|
||||||
)
|
user_msg_object
|
||||||
with urllib.request.urlopen(req) as response:
|
]
|
||||||
res_data = json.loads(response.read().decode("utf-8"))
|
msg = call_ollama_chat(messages, is_vision=True)
|
||||||
msg = res_data.get("message", {})
|
else:
|
||||||
tool_calls = msg.get("tool_calls", [])
|
clean_db_history = [dict(m) for m in db_history]
|
||||||
raw_text_content = msg.get("content", "")
|
for m in clean_db_history:
|
||||||
|
m.pop("images", None)
|
||||||
|
messages = [{"role": "system", "content": system_prompt_content}] + clean_db_history + [user_msg_object]
|
||||||
|
msg = call_ollama_chat(messages, tools=TOOLS_SCHEMA, is_vision=False)
|
||||||
|
|
||||||
#tool_calls = inject_tools_if_needed(user_message, raw_text_content, tool_calls)
|
tool_calls = msg.get("tool_calls", [])
|
||||||
|
|
||||||
if tool_calls:
|
if tool_calls:
|
||||||
logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})")
|
logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})")
|
||||||
messages.append(msg)
|
messages.append(msg)
|
||||||
|
|
||||||
for tool in tool_calls:
|
for tool in tool_calls:
|
||||||
fn_name = tool["function"]["name"]
|
fn_name = tool["function"]["name"]
|
||||||
fn_args = tool["function"].get("arguments", {})
|
fn_args = tool["function"].get("arguments", {})
|
||||||
logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}")
|
logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}")
|
||||||
tool_result_content = ""
|
tool_result_content = ""
|
||||||
|
action_cfg = db_get_tool_action(fn_name)
|
||||||
|
|
||||||
action_cfg = db_get_tool_action(fn_name)
|
# ANCHOR[TASK_INTERACTIVE_DISPATCH]
|
||||||
|
if fn_name == "db_get_tasks":
|
||||||
|
raw_tasks = db_get_tasks(user_id)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
if fn_name == "db_confirm_prompt_preview":
|
elif fn_name == "db_preview_prompt_merge":
|
||||||
if session_state and session_state.get("state_type") == "PROMPT_PREVIEW":
|
proposed_text = fn_args.get("prompt_text") or fn_args.get("proposed_prompt") or ""
|
||||||
draft_text = ""
|
if isinstance(fn_args, str):
|
||||||
if session_state.get("data_json") and isinstance(session_state["data_json"], dict):
|
proposed_text = fn_args
|
||||||
draft_text = session_state["data_json"].get("draft_text", "")
|
|
||||||
else:
|
|
||||||
draft_text = session_state.get("pending_data", "")
|
|
||||||
|
|
||||||
db_add_system_prompt("main_agent", draft_text)
|
merged_prompt = build_prompt_preview_merge(db_get_active_system_prompt(), user_message, proposed_text)
|
||||||
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0})
|
db_set_session_state(session_id, "PROMPT_PREVIEW", {"draft_text": merged_prompt, "idle_turns": 0})
|
||||||
|
|
||||||
if action_cfg and action_cfg.get("bypass_llm"):
|
with get_db_connection() as conn_fix:
|
||||||
reply_text = action_cfg["success_template"]
|
conn_fix.cursor().execute("""
|
||||||
if action_cfg.get("follow_up_question"):
|
UPDATE chat_messages
|
||||||
reply_text += f"\n\n{action_cfg['follow_up_question']}"
|
SET is_ephemeral = 1
|
||||||
|
WHERE id = (SELECT MAX(id) FROM chat_messages WHERE session_id = ? AND role = 'user')
|
||||||
|
""", (session_id,))
|
||||||
|
conn_fix.commit()
|
||||||
|
|
||||||
reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip()
|
preview_reply = (
|
||||||
is_eph = action_cfg.get("is_ephemeral", 1)
|
f"Ваше изменение успешно предпросмотрено. Полный обновленный системный промпт теперь выглядит так:\n\n"
|
||||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph)
|
f"{merged_prompt}\n\n"
|
||||||
return reply_text, db_get_chat_history(session_id), {
|
f"Для применения изменений подтвердите действие («подтверждаю») или отмените («отмена»)."
|
||||||
"type": action_cfg.get("action_type"),
|
)
|
||||||
"buttons": action_cfg.get("buttons", [])
|
db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1)
|
||||||
}
|
return clean_raw_tool_tags(preview_reply), db_get_chat_history(session_id), {
|
||||||
tool_result_content = json.dumps({"status": "success"}, ensure_ascii=False)
|
"type": "PROMPT_PREVIEW",
|
||||||
else:
|
"buttons": [
|
||||||
err_reply = "Нет активного превью для подтверждения."
|
{"label": "Подтвердить", "value": "подтверждаю", "style": "primary"},
|
||||||
db_save_chat_message(session_id, "assistant", err_reply, is_ephemeral=1)
|
{"label": "Отменить", "value": "отмена", "style": "danger"}
|
||||||
return err_reply, db_get_chat_history(session_id), None
|
]
|
||||||
|
}
|
||||||
|
|
||||||
elif fn_name == "db_cancel_prompt_preview":
|
elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]:
|
||||||
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0})
|
tool_result_content = json.dumps({"system_prompt": db_get_active_system_prompt()}, ensure_ascii=False)
|
||||||
if action_cfg and action_cfg.get("bypass_llm"):
|
|
||||||
reply_text = action_cfg["success_template"]
|
|
||||||
if action_cfg.get("follow_up_question"):
|
|
||||||
reply_text += f"\n\n{action_cfg['follow_up_question']}"
|
|
||||||
|
|
||||||
reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip()
|
elif fn_name == "db_get_snapshots":
|
||||||
is_eph = action_cfg.get("is_ephemeral", 1)
|
snapshots_res = db_get_snapshots(session_id=session_id, date_str=fn_args.get("date_str"), original_user_message=user_message)
|
||||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph)
|
tool_result_content = json.dumps(snapshots_res, ensure_ascii=False)
|
||||||
return reply_text, db_get_chat_history(session_id), {
|
|
||||||
"type": action_cfg.get("action_type"),
|
|
||||||
"buttons": action_cfg.get("buttons", [])
|
|
||||||
}
|
|
||||||
tool_result_content = json.dumps({"status": "cancelled"}, ensure_ascii=False)
|
|
||||||
|
|
||||||
elif fn_name == "db_get_snapshots":
|
elif fn_name == "db_get_current_server_time":
|
||||||
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(db_get_current_server_time(), ensure_ascii=False)
|
||||||
tool_result_content = json.dumps(snapshots_res, ensure_ascii=False)
|
|
||||||
|
|
||||||
elif fn_name == "db_get_current_server_time":
|
elif fn_name == "db_get_stats":
|
||||||
tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False)
|
tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False)
|
||||||
|
|
||||||
elif fn_name == "db_get_tasks":
|
elif fn_name == "db_get_anomalies":
|
||||||
tool_result_content = json.dumps(db_get_tasks(user_id), ensure_ascii=False)
|
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 in ["db_get_system_prompt", "db_get_system_prompts"]:
|
elif fn_name == "db_get_session_states":
|
||||||
tool_result_content = json.dumps({"system_prompt": db_get_active_system_prompt()}, ensure_ascii=False)
|
tool_result_content = json.dumps(db_get_session_states(), ensure_ascii=False)
|
||||||
|
|
||||||
elif fn_name == "db_get_stats":
|
elif fn_name == "db_get_rules":
|
||||||
tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False)
|
tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False)
|
||||||
|
|
||||||
elif fn_name == "db_get_anomalies":
|
elif fn_name == "db_get_reference":
|
||||||
tool_result_content = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False)
|
tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False)
|
||||||
|
|
||||||
elif fn_name == "db_get_session_states":
|
elif fn_name == "db_add_task":
|
||||||
tool_result_content = json.dumps(db_get_session_states(), ensure_ascii=False)
|
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_delete_snapshots":
|
elif fn_name == "db_update_task_status":
|
||||||
res = db_delete_snapshots(snapshot_id=fn_args.get("snapshot_id"), day_str=fn_args.get("day_str"))
|
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"))
|
||||||
if action_cfg and action_cfg.get("bypass_llm"):
|
tool_result_content = json.dumps(res, ensure_ascii=False)
|
||||||
reply_text = action_cfg["success_template"]
|
|
||||||
if action_cfg.get("follow_up_question"):
|
|
||||||
reply_text += f"\n\n{action_cfg['follow_up_question']}"
|
|
||||||
reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip()
|
|
||||||
is_eph = action_cfg.get("is_ephemeral", 1)
|
|
||||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph)
|
|
||||||
return reply_text, db_get_chat_history(session_id), {
|
|
||||||
"type": action_cfg.get("action_type"),
|
|
||||||
"buttons": action_cfg.get("buttons", [])
|
|
||||||
}
|
|
||||||
tool_result_content = json.dumps(res, ensure_ascii=False)
|
|
||||||
|
|
||||||
elif fn_name == "db_get_reference":
|
elif fn_name == "db_delete_task":
|
||||||
tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False)
|
res = db_delete_task(user_id=user_id, task_id=str(fn_args.get("task_id", "")).upper())
|
||||||
|
if action_cfg and action_cfg.get("bypass_llm"):
|
||||||
# --- [SECTION 6: PROMPT MERGE & PREVIEW ENGINE] --- # ANCHOR[PROMPT_MERGE_LOGIC]
|
reply_text = action_cfg["success_template"]
|
||||||
elif fn_name == "db_preview_prompt_merge":
|
if action_cfg.get("follow_up_question"):
|
||||||
proposed_text = fn_args.get("prompt_text") or fn_args.get("proposed_prompt") or ""
|
reply_text += f"\n\n{action_cfg['follow_up_question']}"
|
||||||
if isinstance(fn_args, str):
|
reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip()
|
||||||
proposed_text = fn_args
|
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=action_cfg.get("is_ephemeral", 1))
|
||||||
|
return reply_text, db_get_chat_history(session_id), {
|
||||||
current_prompt = db_get_active_system_prompt()
|
"type": action_cfg.get("action_type"),
|
||||||
user_msg_lower = user_message.lower()
|
"buttons": action_cfg.get("buttons", [])
|
||||||
|
|
||||||
# 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
|
|
||||||
proposed_text = "\n".join(new_lines)
|
|
||||||
|
|
||||||
# 2. Обработка добавления / изменения пункта
|
|
||||||
elif 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}")
|
|
||||||
proposed_text = "\n".join(new_lines)
|
|
||||||
|
|
||||||
# Сохраняем черновик в структурированном виде с idle_turns = 0
|
|
||||||
state_payload = {
|
|
||||||
"draft_text": proposed_text,
|
|
||||||
"idle_turns": 0
|
|
||||||
}
|
}
|
||||||
db_set_session_state(session_id, "PROMPT_PREVIEW", state_payload)
|
tool_result_content = json.dumps(res, ensure_ascii=False)
|
||||||
|
|
||||||
# Помечаем последнее сообщение пользователя как эфемерное
|
elif fn_name == "db_delete_snapshots":
|
||||||
with get_db_connection() as conn_fix:
|
res = db_delete_snapshots(snapshot_id=fn_args.get("snapshot_id"), day_str=fn_args.get("day_str"))
|
||||||
cursor_fix = conn_fix.cursor()
|
if action_cfg and action_cfg.get("bypass_llm"):
|
||||||
cursor_fix.execute("""
|
reply_text = action_cfg["success_template"]
|
||||||
UPDATE chat_messages
|
if action_cfg.get("follow_up_question"):
|
||||||
SET is_ephemeral = 1
|
reply_text += f"\n\n{action_cfg['follow_up_question']}"
|
||||||
WHERE id = (SELECT MAX(id) FROM chat_messages WHERE session_id = ? AND role = 'user')
|
reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip()
|
||||||
""", (session_id,))
|
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=action_cfg.get("is_ephemeral", 1))
|
||||||
conn_fix.commit()
|
return reply_text, db_get_chat_history(session_id), {
|
||||||
|
"type": action_cfg.get("action_type"),
|
||||||
preview_reply = (
|
"buttons": action_cfg.get("buttons", [])
|
||||||
f"Ваше изменение успешно предпросмотрено. Полный обновленный системный промпт теперь выглядит так:\n\n"
|
|
||||||
f"{proposed_text}\n\n"
|
|
||||||
f"Для применения изменений подтвердите действие («подтверждаю») или отмените («отмена»)."
|
|
||||||
)
|
|
||||||
db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1)
|
|
||||||
return clean_raw_tool_tags(preview_reply), db_get_chat_history(session_id), {
|
|
||||||
"type": "PROMPT_PREVIEW",
|
|
||||||
"buttons": [
|
|
||||||
{"label": "Подтвердить", "value": "подтверждаю", "style": "primary"},
|
|
||||||
{"label": "Отменить", "value": "отмена", "style": "danger"}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
tool_result_content = json.dumps(res, ensure_ascii=False)
|
||||||
|
|
||||||
elif fn_name == "db_get_rules":
|
messages.append({"role": "tool", "content": tool_result_content})
|
||||||
tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False)
|
|
||||||
|
|
||||||
elif fn_name == "db_add_task":
|
# ANCHOR[SECONDARY_PASS]
|
||||||
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"))
|
sec_msg = call_ollama_chat(messages, is_vision=False)
|
||||||
tool_result_content = json.dumps(res, ensure_ascii=False)
|
raw_content = sec_msg.get("content", "").strip().replace("**", "").replace("*", "")
|
||||||
|
final_content = clean_raw_tool_tags(clean_output(raw_content))
|
||||||
|
|
||||||
elif fn_name == "db_update_task_status":
|
tool_names_called = [t["function"]["name"] for t in tool_calls]
|
||||||
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"))
|
is_output_ephemeral = 1 if any(name in ["db_get_system_prompt", "db_get_system_prompts", "db_preview_prompt_merge"] for name in tool_names_called) else 0
|
||||||
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())
|
|
||||||
if action_cfg and action_cfg.get("bypass_llm"):
|
|
||||||
reply_text = action_cfg["success_template"]
|
|
||||||
if action_cfg.get("follow_up_question"):
|
|
||||||
reply_text += f"\n\n{action_cfg['follow_up_question']}"
|
|
||||||
reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip()
|
|
||||||
is_eph = action_cfg.get("is_ephemeral", 1)
|
|
||||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph)
|
|
||||||
return reply_text, db_get_chat_history(session_id), {
|
|
||||||
"type": action_cfg.get("action_type"),
|
|
||||||
"buttons": action_cfg.get("buttons", [])
|
|
||||||
}
|
|
||||||
tool_result_content = json.dumps(res, ensure_ascii=False)
|
|
||||||
|
|
||||||
messages.append({"role": "tool", "content": tool_result_content})
|
|
||||||
|
|
||||||
# --- [SECTION 7: SECONDARY LLM PASS & CONTEXT GUARD] --- # ANCHOR[SECONDARY_PASS]
|
|
||||||
second_payload = {"model": TEXT_MODEL, "messages": messages, "stream": False, "options": llm_options}
|
|
||||||
sec_req = urllib.request.Request(OLLAMA_URL, data=json.dumps(second_payload).encode("utf-8"), headers={"Content-Type": "application/json"})
|
|
||||||
with urllib.request.urlopen(sec_req) as sec_response:
|
|
||||||
sec_res_data = json.loads(sec_response.read().decode("utf-8"))
|
|
||||||
raw_content = sec_res_data.get("message", {}).get("content", "").strip().replace("**", "").replace("*", "")
|
|
||||||
final_content = clean_raw_tool_tags(clean_output(raw_content))
|
|
||||||
|
|
||||||
# Определяем, был ли вызов служебного инструмента просмотра промпта
|
|
||||||
tool_names_called = [t["function"]["name"] for t in tool_calls]
|
|
||||||
is_output_ephemeral = 1 if any(name in ["db_get_system_prompt", "db_get_system_prompts", "db_preview_prompt_merge"] for name in tool_names_called) else 0
|
|
||||||
|
|
||||||
# Отслеживание отвлечений при активном процессе настройки
|
|
||||||
if session_state and session_state.get("state_type") in ["PROMPT_PREVIEW", "PROMPT_FOLLOWUP"]:
|
|
||||||
idle_count = db_increment_session_idle(session_id)
|
|
||||||
logger.info(f"Смена темы ({session_state.get('state_type')}). Текущий idle_turns: {idle_count}")
|
|
||||||
|
|
||||||
if idle_count == 3:
|
|
||||||
guard_note = "\n\nНапоминание: Завершить настройку системного промпта и очистить сессию?"
|
|
||||||
final_content += guard_note
|
|
||||||
db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_output_ephemeral)
|
|
||||||
return final_content, db_get_chat_history(session_id), {
|
|
||||||
"type": "FOLLOW_UP_ACTION",
|
|
||||||
"buttons": [
|
|
||||||
{"label": "Да, закончить", "value": "нет, закончить настройку", "style": "primary"},
|
|
||||||
{"label": "Показать промпт", "value": "покажи системный промпт", "style": "secondary"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
elif idle_count > 3:
|
|
||||||
logger.info(f"Автоочистка сессии: оператор переключился на другую тему (idle_turns={idle_count}).")
|
|
||||||
db_clear_session_state(session_id)
|
|
||||||
db_purge_ephemeral_messages(session_id)
|
|
||||||
|
|
||||||
db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_output_ephemeral)
|
|
||||||
return final_content, db_get_chat_history(session_id), None
|
|
||||||
|
|
||||||
# Если вызовов инструментов не было (обычный текстовый диалог)
|
|
||||||
raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "")
|
|
||||||
content_str = clean_raw_tool_tags(clean_output(raw_str))
|
|
||||||
final_reply = content_str or "Запрос обработан."
|
|
||||||
|
|
||||||
|
# Topic Drift & Context Guard
|
||||||
if session_state and session_state.get("state_type") in ["PROMPT_PREVIEW", "PROMPT_FOLLOWUP"]:
|
if session_state and session_state.get("state_type") in ["PROMPT_PREVIEW", "PROMPT_FOLLOWUP"]:
|
||||||
idle_count = db_increment_session_idle(session_id)
|
idle_count = db_increment_session_idle(session_id)
|
||||||
logger.info(f"Текстовый диалог вне настройки ({session_state.get('state_type')}). Текущий idle_turns: {idle_count}")
|
logger.info(f"Смена темы ({session_state.get('state_type')}). Текущий idle_turns: {idle_count}")
|
||||||
|
|
||||||
if idle_count == 3:
|
if idle_count == 3:
|
||||||
guard_note = "\n\nНапоминание: Завершить настройку системного промпта и очистить сессию?"
|
guard_note = "\n\nНапоминание: Завершить настройку системного промпта и очистить сессию?"
|
||||||
final_reply += guard_note
|
final_content += guard_note
|
||||||
db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0)
|
db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_output_ephemeral)
|
||||||
return final_reply, db_get_chat_history(session_id), {
|
return final_content, db_get_chat_history(session_id), {
|
||||||
"type": "FOLLOW_UP_ACTION",
|
"type": "FOLLOW_UP_ACTION",
|
||||||
"buttons": [
|
"buttons": [
|
||||||
{"label": "Да, закончить", "value": "нет, закончить настройку", "style": "primary"},
|
{"label": "Да, закончить", "value": "нет, закончить настройку", "style": "primary"},
|
||||||
@@ -493,8 +280,35 @@ def process_chat_message(
|
|||||||
db_clear_session_state(session_id)
|
db_clear_session_state(session_id)
|
||||||
db_purge_ephemeral_messages(session_id)
|
db_purge_ephemeral_messages(session_id)
|
||||||
|
|
||||||
db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0)
|
db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_output_ephemeral)
|
||||||
return final_reply, db_get_chat_history(session_id), None
|
return final_content, db_get_chat_history(session_id), None
|
||||||
|
|
||||||
|
# Обычный текстовый ответ без Tool Calls
|
||||||
|
raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "")
|
||||||
|
final_reply = clean_raw_tool_tags(clean_output(raw_str)) or "Запрос обработан."
|
||||||
|
|
||||||
|
if session_state and session_state.get("state_type") in ["PROMPT_PREVIEW", "PROMPT_FOLLOWUP"]:
|
||||||
|
idle_count = db_increment_session_idle(session_id)
|
||||||
|
logger.info(f"Текстовый диалог вне настройки ({session_state.get('state_type')}). Текущий idle_turns: {idle_count}")
|
||||||
|
|
||||||
|
if idle_count == 3:
|
||||||
|
guard_note = "\n\nНапоминание: Завершить настройку системного промпта и очистить сессию?"
|
||||||
|
final_reply += guard_note
|
||||||
|
db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0)
|
||||||
|
return final_reply, db_get_chat_history(session_id), {
|
||||||
|
"type": "FOLLOW_UP_ACTION",
|
||||||
|
"buttons": [
|
||||||
|
{"label": "Да, закончить", "value": "нет, закончить настройку", "style": "primary"},
|
||||||
|
{"label": "Показать промпт", "value": "покажи системный промпт", "style": "secondary"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
elif idle_count > 3:
|
||||||
|
logger.info(f"Автоочистка сессии: оператор переключился на другую тему (idle_turns={idle_count}).")
|
||||||
|
db_clear_session_state(session_id)
|
||||||
|
db_purge_ephemeral_messages(session_id)
|
||||||
|
|
||||||
|
db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0)
|
||||||
|
return final_reply, db_get_chat_history(session_id), None
|
||||||
|
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.exception(f"Непредвиденная ошибка: {ex}")
|
logger.exception(f"Непредвиденная ошибка: {ex}")
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""
|
||||||
|
===============================================================================
|
||||||
|
FILE: modules/web_api/llm/core/fast_path.py
|
||||||
|
ROLE: Детерминированный быстрый перехват команд оператора без вызова LLM:
|
||||||
|
подтверждение, отмена черновиков и завершение диалога с очисткой памяти.
|
||||||
|
===============================================================================
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ANCHOR[FAST_PATH_IMPORTS]
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any, Tuple, Optional
|
||||||
|
|
||||||
|
from llm.db_tools import (
|
||||||
|
db_add_system_prompt,
|
||||||
|
db_get_tool_action,
|
||||||
|
db_set_session_state,
|
||||||
|
db_clear_session_state,
|
||||||
|
db_purge_ephemeral_messages,
|
||||||
|
db_save_chat_message,
|
||||||
|
db_get_chat_history
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("FAST_PATH")
|
||||||
|
|
||||||
|
|
||||||
|
# ANCHOR[FAST_PATH_ROUTER]
|
||||||
|
def handle_fast_path_intercept(
|
||||||
|
session_id: str,
|
||||||
|
user_message: str,
|
||||||
|
full_user_content: str,
|
||||||
|
session_state: Optional[Dict[str, Any]]
|
||||||
|
) -> Optional[Tuple[str, list, Optional[Dict[str, Any]]]]:
|
||||||
|
"""
|
||||||
|
Проверяет входящее сообщение на детерминированные команды.
|
||||||
|
Если команда перехвачена — возвращает кортеж (reply_text, chat_history, metadata).
|
||||||
|
Если перехват не требуется — возвращает None.
|
||||||
|
"""
|
||||||
|
user_msg_clean = user_message.lower().strip(" .!?:;")
|
||||||
|
|
||||||
|
# 1. Перехват завершения работы с очисткой эфемерных сообщений
|
||||||
|
if user_msg_clean in ["нет, спасибо", "нет, закончить настройку", "закончить настройку", "завершить", "нет"]:
|
||||||
|
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
|
||||||
|
|
||||||
|
# 2. Перехват подтверждения или отмены превью системного промпта
|
||||||
|
if session_state and session_state.get("state_type") == "PROMPT_PREVIEW":
|
||||||
|
draft_text = ""
|
||||||
|
if session_state.get("data_json") and isinstance(session_state["data_json"], dict):
|
||||||
|
draft_text = session_state["data_json"].get("draft_text", "")
|
||||||
|
else:
|
||||||
|
draft_text = session_state.get("pending_data", "")
|
||||||
|
|
||||||
|
if user_msg_clean in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]:
|
||||||
|
db_add_system_prompt("main_agent", draft_text)
|
||||||
|
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0})
|
||||||
|
|
||||||
|
action_cfg = db_get_tool_action("db_confirm_prompt_preview")
|
||||||
|
reply_text = action_cfg["success_template"] if action_cfg else "Системный промпт успешно сохранен и применен в базе данных."
|
||||||
|
if action_cfg and action_cfg.get("follow_up_question"):
|
||||||
|
reply_text += f"\n\n{action_cfg['follow_up_question']}"
|
||||||
|
|
||||||
|
reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip()
|
||||||
|
|
||||||
|
is_eph = action_cfg.get("is_ephemeral", 1) if action_cfg else 1
|
||||||
|
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1)
|
||||||
|
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph)
|
||||||
|
return reply_text, db_get_chat_history(session_id), {
|
||||||
|
"type": action_cfg.get("action_type") if action_cfg else "FOLLOW_UP_ACTION",
|
||||||
|
"buttons": action_cfg.get("buttons", []) if action_cfg else []
|
||||||
|
}
|
||||||
|
|
||||||
|
elif user_msg_clean in ["отмена", "отменить", "отклонить", "назад", "стоп"]:
|
||||||
|
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0})
|
||||||
|
|
||||||
|
action_cfg = db_get_tool_action("db_cancel_prompt_preview")
|
||||||
|
reply_text = action_cfg["success_template"] if action_cfg else "Изменения системного промпта отменены."
|
||||||
|
if action_cfg and action_cfg.get("follow_up_question"):
|
||||||
|
reply_text += f"\n\n{action_cfg['follow_up_question']}"
|
||||||
|
|
||||||
|
reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip()
|
||||||
|
|
||||||
|
is_eph = action_cfg.get("is_ephemeral", 1) if action_cfg else 1
|
||||||
|
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1)
|
||||||
|
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph)
|
||||||
|
return reply_text, db_get_chat_history(session_id), {
|
||||||
|
"type": action_cfg.get("action_type") if action_cfg else "FOLLOW_UP_ACTION",
|
||||||
|
"buttons": action_cfg.get("buttons", []) if action_cfg else []
|
||||||
|
}
|
||||||
|
|
||||||
|
return None
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""
|
||||||
|
===============================================================================
|
||||||
|
FILE: modules/web_api/llm/core/ollama_client.py
|
||||||
|
ROLE: Транспортный клиент HTTP взаимодействия с локальным API Ollama.
|
||||||
|
===============================================================================
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ANCHOR[OLLAMA_CLIENT_IMPORTS]
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
import logging
|
||||||
|
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"
|
||||||
|
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,
|
||||||
|
"top_p": 0.9
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ANCHOR[OLLAMA_REQUEST_DISPATCHER]
|
||||||
|
def call_ollama_chat(
|
||||||
|
messages: list,
|
||||||
|
tools: Optional[list] = None,
|
||||||
|
is_vision: bool = False,
|
||||||
|
timeout: int = 120
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Отправляет подготовленный массив сообщений в API Ollama.
|
||||||
|
Возвращает разобранный словарь сообщения ответа или генерирует исключение.
|
||||||
|
"""
|
||||||
|
model_name = VISION_MODEL if is_vision else TEXT_MODEL
|
||||||
|
payload = {
|
||||||
|
"model": model_name,
|
||||||
|
"messages": messages,
|
||||||
|
"stream": False,
|
||||||
|
"options": LLM_OPTIONS
|
||||||
|
}
|
||||||
|
|
||||||
|
if tools and not is_vision:
|
||||||
|
payload["tools"] = tools
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
OLLAMA_URL,
|
||||||
|
data=json.dumps(payload).encode("utf-8"),
|
||||||
|
headers={"Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||||
|
res_data = json.loads(response.read().decode("utf-8"))
|
||||||
|
return res_data.get("message", {})
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""
|
||||||
|
===============================================================================
|
||||||
|
FILE: modules/web_api/llm/core/prompt_merger.py
|
||||||
|
ROLE: Алгоритмы парсинга, предпросмотра и детерминированного слияния правок
|
||||||
|
системного промпта (добавление, удаление и замена пунктов).
|
||||||
|
===============================================================================
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ANCHOR[PROMPT_MERGER_IMPORTS]
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
logger = logging.getLogger("PROMPT_MERGER")
|
||||||
|
|
||||||
|
|
||||||
|
# ANCHOR[PROMPT_MERGE_ENGINE]
|
||||||
|
def build_prompt_preview_merge(current_prompt: str, user_message: str, proposed_text: str) -> str:
|
||||||
|
"""
|
||||||
|
Вычисляет результирующий текст промпта на основе намерения пользователя:
|
||||||
|
1. Удаление пункта по номеру.
|
||||||
|
2. Добавление/изменение пункта в соответствующую секцию.
|
||||||
|
"""
|
||||||
|
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
|
||||||
+15
-243
@@ -2,68 +2,37 @@
|
|||||||
===============================================================================
|
===============================================================================
|
||||||
FILE: modules/web_api/main.py
|
FILE: modules/web_api/main.py
|
||||||
PROJECT: SCUD Orion AI (Unified Repository)
|
PROJECT: SCUD Orion AI (Unified Repository)
|
||||||
MODULE: web_api (FastAPI REST Server & Context Management)
|
MODULE: web_api (Main Application Entry Point)
|
||||||
ROLE: Главный шлюз веб-интерфейса, авторизация пользователей (JWT/Bcrypt),
|
ROLE: Инициализация FastAPI приложения, подключение роутеров и статики.
|
||||||
маршрутизация диалогов с LLM, OCR-парсинг файлов и управление задачами.
|
|
||||||
|
|
||||||
AI-CONTEXT-ANCHORS & INVARIANTS:
|
|
||||||
- ANCHOR[SYS_PATH]: Добавляет директорию модуля в sys.path для корректных импортов
|
|
||||||
независимо от рабочей директории запуска (root или web_api).
|
|
||||||
- ANCHOR[STATIC_MOUNT]: Рассчитывает абсолютный путь к папке static/ для надежного
|
|
||||||
рендеринга интерфейса и ассетов (css/js/favicon).
|
|
||||||
- ANCHOR[AUTH_JWT]: Изолирует персональные пространства задач по user_id (sub).
|
|
||||||
- ANCHOR[CHAT_PIPELINE]: Оркестрирует пайплайн парсинга вложений (file_parser) и
|
|
||||||
генерации ответов LLM (agent.process_chat_message).
|
|
||||||
|
|
||||||
DEPENDENCIES:
|
|
||||||
- modules/web_api/llm/agent.py (process_chat_message)
|
|
||||||
- modules/web_api/llm/db_tools.py (db_get_tasks, DB_PATH)
|
|
||||||
- modules/web_api/llm/file_parser.py (extract_text_from_file)
|
|
||||||
===============================================================================
|
===============================================================================
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# --- [SECTION 1: SYSTEM PATHS & IMPORTS] --- # ANCHOR[SYS_PATH]
|
# ANCHOR[APP_INIT_IMPORTS]
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import json
|
|
||||||
import sqlite3
|
|
||||||
import logging
|
import logging
|
||||||
import urllib.request
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import List, Dict, Any, Optional
|
|
||||||
|
|
||||||
# Гарантируем корректный импорт подмодулей web_api независимо от точки запуска
|
|
||||||
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
if CURRENT_DIR not in sys.path:
|
if CURRENT_DIR not in sys.path:
|
||||||
sys.path.insert(0, CURRENT_DIR)
|
sys.path.insert(0, CURRENT_DIR)
|
||||||
|
|
||||||
import jwt
|
from fastapi import FastAPI, HTTPException
|
||||||
from passlib.context import CryptContext
|
|
||||||
from fastapi import FastAPI, Depends, HTTPException, status, UploadFile, File, Form
|
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
# Внутренние модули LLM и БД
|
from routers.auth import router as auth_router
|
||||||
from llm.agent import process_chat_message
|
from routers.admin import router as admin_router
|
||||||
from llm.db_tools import db_get_tasks, DB_PATH
|
from routers.tasks import router as tasks_router
|
||||||
from llm.file_parser import extract_text_from_file
|
from routers.chat import router as chat_router
|
||||||
|
|
||||||
# --- [SECTION 2: CONFIGURATION & SECURITY] --- # ANCHOR[AUTH_CONFIG]
|
# ANCHOR[APP_CONFIG]
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
handlers=[logging.StreamHandler()]
|
handlers=[logging.StreamHandler()]
|
||||||
)
|
)
|
||||||
|
|
||||||
JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure"
|
|
||||||
ALGORITHM = "HS256"
|
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
||||||
security = HTTPBearer()
|
|
||||||
|
|
||||||
STATIC_DIR = os.path.join(CURRENT_DIR, "static")
|
STATIC_DIR = os.path.join(CURRENT_DIR, "static")
|
||||||
|
|
||||||
app = FastAPI(title="SCUD Orion AI Context API", version="2.5")
|
app = FastAPI(title="SCUD Orion AI Context API", version="2.5")
|
||||||
@@ -79,57 +48,15 @@ async def validation_exception_handler(request, exc):
|
|||||||
content={"detail": exc.errors(), "body": str(exc)}
|
content={"detail": exc.errors(), "body": str(exc)}
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- [SECTION 3: DATABASE & TOKEN HELPERS] --- # ANCHOR[DB_HELPERS]
|
# ANCHOR[ROUTER_REGISTRATION]
|
||||||
def get_db():
|
app.include_router(auth_router)
|
||||||
"""Создает безопасное соединение с SQLite БД модуля."""
|
app.include_router(admin_router)
|
||||||
conn = sqlite3.connect(DB_PATH)
|
app.include_router(tasks_router)
|
||||||
conn.row_factory = sqlite3.Row
|
app.include_router(chat_router)
|
||||||
return conn
|
|
||||||
|
|
||||||
def create_access_token(user_id: int, username: str, is_admin: bool) -> str:
|
# ANCHOR[ROOT_STATIC_ROUTES]
|
||||||
payload = {
|
|
||||||
"sub": str(user_id),
|
|
||||||
"username": username,
|
|
||||||
"is_admin": is_admin,
|
|
||||||
"exp": datetime.utcnow() + timedelta(days=30)
|
|
||||||
}
|
|
||||||
return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM)
|
|
||||||
|
|
||||||
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]:
|
|
||||||
try:
|
|
||||||
token = credentials.credentials
|
|
||||||
payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM])
|
|
||||||
user_id = int(payload.get("sub"))
|
|
||||||
username = payload.get("username")
|
|
||||||
is_admin = bool(payload.get("is_admin", False))
|
|
||||||
return {"id": user_id, "username": username, "is_admin": is_admin}
|
|
||||||
except Exception as e:
|
|
||||||
logging.warning(f"Auth error: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Недействительный или просроченный токен авторизации",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Pydantic-схемы валидации запросов
|
|
||||||
class AuthRequest(BaseModel):
|
|
||||||
username: str
|
|
||||||
password: str
|
|
||||||
|
|
||||||
class CreateUserRequest(BaseModel):
|
|
||||||
username: str
|
|
||||||
password: str
|
|
||||||
full_name: Optional[str] = None
|
|
||||||
is_admin: Optional[bool] = False
|
|
||||||
|
|
||||||
class ChangePasswordRequest(BaseModel):
|
|
||||||
old_password: str
|
|
||||||
new_password: str
|
|
||||||
|
|
||||||
# --- [SECTION 4: STATIC FILES & SPA ROUTES] --- # ANCHOR[STATIC_MOUNT]
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def read_root():
|
def read_root():
|
||||||
"""Отдает главную страницу панели управления."""
|
|
||||||
index_path = os.path.join(STATIC_DIR, "index.html")
|
index_path = os.path.join(STATIC_DIR, "index.html")
|
||||||
if os.path.exists(index_path):
|
if os.path.exists(index_path):
|
||||||
return FileResponse(index_path)
|
return FileResponse(index_path)
|
||||||
@@ -142,164 +69,9 @@ async def favicon():
|
|||||||
return FileResponse(file_path)
|
return FileResponse(file_path)
|
||||||
raise HTTPException(status_code=404)
|
raise HTTPException(status_code=404)
|
||||||
|
|
||||||
# --- [SECTION 5: AUTHENTICATION & USER MANAGEMENT] --- # ANCHOR[AUTH_JWT]
|
|
||||||
@app.post("/api/v1/auth/login")
|
|
||||||
def login(req: AuthRequest):
|
|
||||||
username = req.username.strip().lower()
|
|
||||||
logging.info(f"===> Попытка входа для пользователя: {username}")
|
|
||||||
|
|
||||||
conn = get_db()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", (username,))
|
|
||||||
user = cursor.fetchone()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
if not user 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}
|
|
||||||
|
|
||||||
@app.post("/api/v1/auth/change-password")
|
|
||||||
def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = Depends(get_current_user)):
|
|
||||||
if not req.new_password or len(req.new_password) < 4:
|
|
||||||
raise HTTPException(status_code=400, detail="Новый пароль должен содержать минимум 4 символа")
|
|
||||||
|
|
||||||
conn = get_db()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute("SELECT password_hash FROM users WHERE id = ?", (current_user["id"],))
|
|
||||||
user = cursor.fetchone()
|
|
||||||
|
|
||||||
if not user or not pwd_context.verify(req.old_password, user["password_hash"]):
|
|
||||||
conn.close()
|
|
||||||
raise HTTPException(status_code=400, detail="Неверный старый пароль")
|
|
||||||
|
|
||||||
new_hash = pwd_context.hash(req.new_password)
|
|
||||||
cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, current_user["id"]))
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
logging.info(f"Пароль успешно изменен для пользователя ID: {current_user['id']}")
|
|
||||||
return {"status": "success", "message": "Пароль успешно изменен"}
|
|
||||||
|
|
||||||
@app.get("/api/v1/admin/users")
|
|
||||||
def list_users(current_user: Dict[str, Any] = Depends(get_current_user)):
|
|
||||||
if not current_user["is_admin"]:
|
|
||||||
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
|
|
||||||
|
|
||||||
conn = get_db()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute("SELECT id, username, full_name, is_admin, created_at FROM users ORDER BY id ASC")
|
|
||||||
users = [dict(r) for r in cursor.fetchall()]
|
|
||||||
conn.close()
|
|
||||||
return users
|
|
||||||
|
|
||||||
@app.post("/api/v1/admin/users")
|
|
||||||
def create_user(req: CreateUserRequest, current_user: Dict[str, Any] = Depends(get_current_user)):
|
|
||||||
if not current_user["is_admin"]:
|
|
||||||
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
|
|
||||||
|
|
||||||
username = req.username.strip().lower()
|
|
||||||
if not username or not req.password:
|
|
||||||
raise HTTPException(status_code=400, detail="Заполните имя пользователя и пароль")
|
|
||||||
|
|
||||||
conn = get_db()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute("SELECT id FROM users WHERE username = ?", (username,))
|
|
||||||
if cursor.fetchone():
|
|
||||||
conn.close()
|
|
||||||
raise HTTPException(status_code=400, detail="Пользователь с таким именем уже существует")
|
|
||||||
|
|
||||||
pwd_hash = pwd_context.hash(req.password)
|
|
||||||
full_name = req.full_name.strip() if req.full_name else None
|
|
||||||
is_admin = 1 if req.is_admin else 0
|
|
||||||
|
|
||||||
cursor.execute(
|
|
||||||
"INSERT INTO users (username, password_hash, full_name, is_admin) VALUES (?, ?, ?, ?)",
|
|
||||||
(username, pwd_hash, full_name, is_admin)
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
logging.info(f"Создан пользователь: {username} (admin={is_admin}) админом {current_user['username']}")
|
|
||||||
return {"status": "success", "message": f"Пользователь {username} создан"}
|
|
||||||
|
|
||||||
@app.delete("/api/v1/admin/users/{user_id}")
|
|
||||||
def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current_user)):
|
|
||||||
if not current_user["is_admin"]:
|
|
||||||
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
|
|
||||||
|
|
||||||
if user_id == current_user["id"]:
|
|
||||||
raise HTTPException(status_code=400, detail="Нельзя удалить самого себя")
|
|
||||||
|
|
||||||
conn = get_db()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
logging.info(f"Удален пользователь ID: {user_id}")
|
|
||||||
return {"status": "success", "message": "Пользователь удален"}
|
|
||||||
|
|
||||||
# --- [SECTION 6: TASK TRACKER & LLM CHAT PIPELINE] --- # ANCHOR[CHAT_PIPELINE]
|
|
||||||
@app.get("/api/v1/tasks")
|
|
||||||
def get_tasks(user: Dict[str, Any] = Depends(get_current_user)):
|
|
||||||
"""Получить задачи текущего авторизованного пользователя."""
|
|
||||||
return db_get_tasks(user_id=user["id"])
|
|
||||||
|
|
||||||
# --- ЧАТ С АВТОРИЗАЦИЕЙ ---
|
|
||||||
@app.post("/api/v1/chat")
|
|
||||||
async def chat_endpoint(
|
|
||||||
session_id: str = Form("web_session_main"),
|
|
||||||
message: str = Form(""),
|
|
||||||
file: Optional[UploadFile] = File(default=None),
|
|
||||||
current_user: dict = 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)
|
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
return {"reply": reply, "history": history, "action_type": action_type}
|
|
||||||
|
|
||||||
|
|
||||||
# --- ГОСТЕВОЙ ЧАТ ---
|
|
||||||
@app.post("/api/v1/chat/guest")
|
|
||||||
async def guest_chat_endpoint(
|
|
||||||
session_id: str = Form("web_session_main"),
|
|
||||||
message: str = Form(""),
|
|
||||||
file: Optional[UploadFile] = File(default=None)
|
|
||||||
):
|
|
||||||
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}
|
|
||||||
|
|
||||||
# --- [SECTION 7: STATIC FALLBACK ROUTER] --- # ANCHOR[STATIC_FALLBACK]
|
|
||||||
@app.get("/{file_path:path}")
|
@app.get("/{file_path:path}")
|
||||||
def serve_static_fallback(file_path: str):
|
def serve_static_fallback(file_path: str):
|
||||||
clean_path = file_path.lstrip("/")
|
clean_path = file_path.lstrip("/")
|
||||||
|
|
||||||
target = os.path.join(STATIC_DIR, clean_path)
|
target = os.path.join(STATIC_DIR, clean_path)
|
||||||
if os.path.isfile(target):
|
if os.path.isfile(target):
|
||||||
return FileResponse(target)
|
return FileResponse(target)
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""
|
||||||
|
===============================================================================
|
||||||
|
FILE: modules/web_api/routers/admin.py
|
||||||
|
ROLE: Администрирование пользователей и прав доступа.
|
||||||
|
===============================================================================
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ANCHOR[ADMIN_ROUTER_IMPORTS]
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .auth import get_current_user, get_db, pwd_context
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
|
||||||
|
|
||||||
|
# ANCHOR[ADMIN_SCHEMAS]
|
||||||
|
class CreateUserRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
is_admin: Optional[bool] = False
|
||||||
|
|
||||||
|
# ANCHOR[ADMIN_ENDPOINTS]
|
||||||
|
@router.get("/users")
|
||||||
|
def list_users(current_user: Dict[str, Any] = Depends(get_current_user)):
|
||||||
|
if not current_user["is_admin"]:
|
||||||
|
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT id, username, full_name, is_admin, created_at FROM users ORDER BY id ASC")
|
||||||
|
users = [dict(r) for r in cursor.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
return users
|
||||||
|
|
||||||
|
@router.post("/users")
|
||||||
|
def create_user(req: CreateUserRequest, current_user: Dict[str, Any] = Depends(get_current_user)):
|
||||||
|
if not current_user["is_admin"]:
|
||||||
|
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
|
||||||
|
|
||||||
|
username = req.username.strip().lower()
|
||||||
|
if not username or not req.password:
|
||||||
|
raise HTTPException(status_code=400, detail="Заполните имя пользователя и пароль")
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT id FROM users WHERE username = ?", (username,))
|
||||||
|
if cursor.fetchone():
|
||||||
|
conn.close()
|
||||||
|
raise HTTPException(status_code=400, detail="Пользователь с таким именем уже существует")
|
||||||
|
|
||||||
|
pwd_hash = pwd_context.hash(req.password)
|
||||||
|
full_name = req.full_name.strip() if req.full_name else None
|
||||||
|
is_admin = 1 if req.is_admin else 0
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO users (username, password_hash, full_name, is_admin) VALUES (?, ?, ?, ?)",
|
||||||
|
(username, pwd_hash, full_name, is_admin)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
logging.info(f"Создан пользователь: {username} (admin={is_admin}) админом {current_user['username']}")
|
||||||
|
return {"status": "success", "message": f"Пользователь {username} создан"}
|
||||||
|
|
||||||
|
@router.delete("/users/{user_id}")
|
||||||
|
def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current_user)):
|
||||||
|
if not current_user["is_admin"]:
|
||||||
|
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
|
||||||
|
|
||||||
|
if user_id == current_user["id"]:
|
||||||
|
raise HTTPException(status_code=400, detail="Нельзя удалить самого себя")
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
logging.info(f"Удален пользователь ID: {user_id}")
|
||||||
|
return {"status": "success", "message": "Пользователь удален"}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""
|
||||||
|
===============================================================================
|
||||||
|
FILE: modules/web_api/routers/auth.py
|
||||||
|
ROLE: Аутентификация, валидация JWT-токенов и управление паролями.
|
||||||
|
===============================================================================
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ANCHOR[AUTH_ROUTER_IMPORTS]
|
||||||
|
import sqlite3
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from llm.db_tools import DB_PATH
|
||||||
|
|
||||||
|
JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure"
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
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
|
||||||
|
|
||||||
|
def create_access_token(user_id: int, username: str, is_admin: bool) -> str:
|
||||||
|
payload = {
|
||||||
|
"sub": str(user_id),
|
||||||
|
"username": username,
|
||||||
|
"is_admin": is_admin,
|
||||||
|
"exp": datetime.utcnow() + timedelta(days=30)
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
token = credentials.credentials
|
||||||
|
payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM])
|
||||||
|
user_id = int(payload.get("sub"))
|
||||||
|
username = payload.get("username")
|
||||||
|
is_admin = bool(payload.get("is_admin", False))
|
||||||
|
return {"id": user_id, "username": username, "is_admin": is_admin}
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning(f"Auth error: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Недействительный или просроченный токен авторизации",
|
||||||
|
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,))
|
||||||
|
user = cursor.fetchone()
|
||||||
|
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:
|
||||||
|
raise HTTPException(status_code=400, detail="Новый пароль должен содержать минимум 4 символа")
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT password_hash FROM users WHERE id = ?", (current_user["id"],))
|
||||||
|
user = cursor.fetchone()
|
||||||
|
|
||||||
|
if not user or not pwd_context.verify(req.old_password, user["password_hash"]):
|
||||||
|
conn.close()
|
||||||
|
raise HTTPException(status_code=400, detail="Неверный старый пароль")
|
||||||
|
|
||||||
|
new_hash = pwd_context.hash(req.new_password)
|
||||||
|
cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, current_user["id"]))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
logging.info(f"Пароль успешно изменен для пользователя ID: {current_user['id']}")
|
||||||
|
return {"status": "success", "message": "Пароль успешно изменен"}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""
|
||||||
|
===============================================================================
|
||||||
|
FILE: modules/web_api/routers/chat.py
|
||||||
|
ROLE: Маршрутизация диалогов с LLM (авторизованный и гостевой чаты).
|
||||||
|
===============================================================================
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ANCHOR[CHAT_ROUTER_IMPORTS]
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from fastapi import APIRouter, Depends, UploadFile, File, Form
|
||||||
|
|
||||||
|
from .auth import get_current_user
|
||||||
|
from llm.agent import process_chat_message
|
||||||
|
from llm.file_parser import extract_text_from_file
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/chat", tags=["chat"])
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
return {"reply": reply, "history": history, "action_type": action_type}
|
||||||
|
|
||||||
|
@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}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""
|
||||||
|
===============================================================================
|
||||||
|
FILE: modules/web_api/routers/tasks.py
|
||||||
|
ROLE: REST API управления задачами (GET / POST / PATCH / DELETE).
|
||||||
|
===============================================================================
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ANCHOR[TASKS_ROUTER_IMPORTS]
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .auth import get_current_user
|
||||||
|
from llm.db_tools import (
|
||||||
|
db_get_tasks,
|
||||||
|
db_add_task,
|
||||||
|
db_update_task_status,
|
||||||
|
db_delete_task
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/tasks", tags=["tasks"])
|
||||||
|
|
||||||
|
# ANCHOR[TASKS_SCHEMAS]
|
||||||
|
class CreateTaskRequest(BaseModel):
|
||||||
|
title: str
|
||||||
|
priority: Optional[str] = "MEDIUM"
|
||||||
|
module: Optional[str] = "general"
|
||||||
|
due_date: Optional[str] = None
|
||||||
|
|
||||||
|
class UpdateTaskRequest(BaseModel):
|
||||||
|
status: Optional[str] = "COMPLETED"
|
||||||
|
due_date: Optional[str] = None
|
||||||
|
|
||||||
|
# ANCHOR[TASKS_ENDPOINTS]
|
||||||
|
@router.get("")
|
||||||
|
def get_tasks(user: Dict[str, Any] = Depends(get_current_user)):
|
||||||
|
"""Получить список всех задач текущего авторизованного пользователя."""
|
||||||
|
return db_get_tasks(user_id=user["id"])
|
||||||
|
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
if "error" in res:
|
||||||
|
raise HTTPException(status_code=404, 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)
|
||||||
|
if "error" in res:
|
||||||
|
raise HTTPException(status_code=404, detail=res["error"])
|
||||||
|
return res
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body class="bg-slate-100 text-slate-800 h-[100dvh] w-full flex flex-col font-sans overflow-hidden">
|
<body class="bg-slate-100 text-slate-800 h-[100dvh] w-full flex flex-col font-sans overflow-hidden">
|
||||||
|
|
||||||
<!-- Окно авторизации -->
|
<!-- 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 id="auth-modal" class="fixed inset-0 bg-slate-900/80 backdrop-blur-md z-50 flex items-center justify-center p-4">
|
||||||
<div class="bg-white rounded-2xl p-6 sm:p-8 max-w-md w-full shadow-2xl border border-slate-200">
|
<div class="bg-white rounded-2xl p-6 sm:p-8 max-w-md w-full shadow-2xl border border-slate-200">
|
||||||
<div class="flex items-center space-x-3 mb-6">
|
<div class="flex items-center space-x-3 mb-6">
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 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="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">
|
<div class="flex justify-between items-center mb-4">
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
</div>
|
</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 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="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">
|
<div class="flex justify-between items-center mb-4 pb-2 border-b border-slate-200">
|
||||||
@@ -129,7 +129,7 @@
|
|||||||
</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">
|
<header class="bg-white border-b border-slate-200 px-4 py-2.5 flex justify-between items-center shadow-sm shrink-0 z-20">
|
||||||
<div class="flex items-center space-x-2.5">
|
<div class="flex items-center space-x-2.5">
|
||||||
<div class="bg-indigo-600 text-white p-2 rounded-xl shrink-0">
|
<div class="bg-indigo-600 text-white p-2 rounded-xl shrink-0">
|
||||||
@@ -170,16 +170,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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 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="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 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">
|
<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>
|
<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-sm font-bold text-slate-800">Перетащите файл сюда</p>
|
||||||
<p class="text-xs text-slate-500">Поддерживаются PDF, изображения, таблицы, TXT</p>
|
<p class="text-xs text-slate-500">Поддерживаются PDF, изображения, таблицы, TXT</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm">
|
<div class="bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm">
|
||||||
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
|
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
|
||||||
<i class="fa-solid fa-robot mr-1"></i> ИИ-Ассистент
|
<i class="fa-solid fa-robot mr-1"></i> ИИ-Ассистент
|
||||||
@@ -202,9 +202,9 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Поле ввода -->
|
||||||
<div class="p-2.5 pb-6 bg-white border-t border-slate-200 shrink-0 z-10 shadow-lg">
|
<div class="p-2.5 pb-6 bg-white border-t border-slate-200 shrink-0 z-10 shadow-lg">
|
||||||
<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">
|
<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="Прикрепить файл">
|
<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>
|
<i class="fa-solid fa-paperclip text-lg"></i>
|
||||||
@@ -223,7 +223,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Выезжающая панель (Drawer) -->
|
<!-- 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 id="drawer-backdrop" onclick="toggleDrawer()" class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm hidden transition-opacity z-40"></div>
|
||||||
|
|
||||||
<aside id="task-drawer" class="fixed right-0 top-0 h-full w-full sm:w-[420px] bg-white border-l border-slate-200 shadow-2xl transform translate-x-full transition-transform duration-300 ease-in-out z-50 flex flex-col">
|
<aside id="task-drawer" class="fixed right-0 top-0 h-full w-full sm:w-[420px] bg-white border-l border-slate-200 shadow-2xl transform translate-x-full transition-transform duration-300 ease-in-out z-50 flex flex-col">
|
||||||
@@ -241,11 +241,12 @@
|
|||||||
</div>
|
</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">
|
<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('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('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('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>
|
<button type="button" onclick="setFilter('COMPLETED')" id="filter-COMPLETED" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap">Завершенные</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="tasks-container" class="flex-1 overflow-y-auto p-3.5 space-y-3 bg-slate-50/50 pb-8">
|
<div id="tasks-container" class="flex-1 overflow-y-auto p-3.5 space-y-3 bg-slate-50/50 pb-8">
|
||||||
|
|||||||
@@ -1,4 +1,19 @@
|
|||||||
// Вспомогательная функция для автоматического изменения высоты текстового поля
|
/**
|
||||||
|
===============================================================================
|
||||||
|
FILE: modules/web_api/static/js/chat.js
|
||||||
|
ROLE: Клиентский интерфейс диалога, Drag-and-Drop вложений, генерация динамических
|
||||||
|
кнопок подтверждений и рендеринг Generative UI интерактивного виджета задач.
|
||||||
|
|
||||||
|
AI-CONTEXT-ANCHORS:
|
||||||
|
- ANCHOR[FILE_UPLOAD_UTILS]: Выбор, превью и очистка прикрепленных файлов.
|
||||||
|
- ANCHOR[INTERACTIVE_BUTTONS_CORE]: Деактивация и быстрая отправка нажатых кнопок.
|
||||||
|
- ANCHOR[INTERACTIVE_TASK_WIDGET_JS]: Рендерер и REST-обработчики виджета задач.
|
||||||
|
- ANCHOR[CHAT_SEND_PIPELINE]: Главный метод sendMessage и вставка сообщений в DOM.
|
||||||
|
- ANCHOR[DRAG_DROP_HANDLERS]: Обработка перетаскивания файлов в окно чата.
|
||||||
|
===============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
// --- [SECTION 1: FILE UPLOAD HELPERS] --- # ANCHOR[FILE_UPLOAD_UTILS]
|
||||||
function updateInputHeight(el) {
|
function updateInputHeight(el) {
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.style.height = "24px";
|
el.style.height = "24px";
|
||||||
@@ -36,7 +51,7 @@ function clearAttachedFile() {
|
|||||||
if (previewContainer) previewContainer.classList.add("hidden");
|
if (previewContainer) previewContainer.classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Деактивация всех старых кнопок в истории
|
// --- [SECTION 2: ACTION BUTTONS CORE] --- # ANCHOR[INTERACTIVE_BUTTONS_CORE]
|
||||||
function disableAllActionButtons() {
|
function disableAllActionButtons() {
|
||||||
const allBtnContainers = document.querySelectorAll(".action-buttons-container");
|
const allBtnContainers = document.querySelectorAll(".action-buttons-container");
|
||||||
allBtnContainers.forEach(container => {
|
allBtnContainers.forEach(container => {
|
||||||
@@ -47,7 +62,6 @@ function disableAllActionButtons() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Быстрая отправка текста кнопки
|
|
||||||
function handleActionButtonClick(text) {
|
function handleActionButtonClick(text) {
|
||||||
disableAllActionButtons();
|
disableAllActionButtons();
|
||||||
const input = document.getElementById("user-input");
|
const input = document.getElementById("user-input");
|
||||||
@@ -57,6 +71,214 @@ function handleActionButtonClick(text) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- [SECTION 3: INTERACTIVE TASK WIDGET] --- # ANCHOR[INTERACTIVE_TASK_WIDGET_JS]
|
||||||
|
let activeWidgetTasksMap = new Map();
|
||||||
|
|
||||||
|
function renderTaskItemsHtml(tasks, filterStatus) {
|
||||||
|
const filtered = tasks.filter(t => {
|
||||||
|
if (filterStatus === "ALL") return true;
|
||||||
|
return t.status === filterStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
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>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
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>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
</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="Удалить задачу">
|
||||||
|
<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");
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- Список элементов задач -->
|
||||||
|
<div class="widget-items-container space-y-2 max-h-72 overflow-y-auto pr-1">
|
||||||
|
${itemsHtml}
|
||||||
|
</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> Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterTaskWidget(widgetId, status, btnEl) {
|
||||||
|
const state = activeWidgetTasksMap.get(widgetId);
|
||||||
|
if (!state) return;
|
||||||
|
state.filter = status;
|
||||||
|
|
||||||
|
const widgetEl = document.getElementById(widgetId);
|
||||||
|
if (!widgetEl) return;
|
||||||
|
|
||||||
|
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";
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/tasks/${taskId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer " + token
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ status: newStatus })
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Task Patch Error]", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTaskInline(taskId) {
|
||||||
|
if (!confirm(`Удалить задачу ${taskId}?`)) 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/${taskId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "Authorization": "Bearer " + token }
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Task Delete Error]", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = "";
|
||||||
|
// Мгновенная выгрузка свежего списка без обращения к LLM
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- [SECTION 4: CHAT SEND PIPELINE] --- # ANCHOR[CHAT_SEND_PIPELINE]
|
||||||
async function sendMessage(e) {
|
async function sendMessage(e) {
|
||||||
if (e && e.preventDefault) e.preventDefault();
|
if (e && e.preventDefault) e.preventDefault();
|
||||||
|
|
||||||
@@ -69,7 +291,6 @@ async function sendMessage(e) {
|
|||||||
|
|
||||||
if (!text && !selectedFile) return;
|
if (!text && !selectedFile) return;
|
||||||
|
|
||||||
// Деактивируем предыдущие интерактивные кнопки
|
|
||||||
disableAllActionButtons();
|
disableAllActionButtons();
|
||||||
|
|
||||||
let userDisplayHtml = escapeHtml(text);
|
let userDisplayHtml = escapeHtml(text);
|
||||||
@@ -131,7 +352,6 @@ async function sendMessage(e) {
|
|||||||
const assistantTitle = isGuest ? "Локальная нейросеть (Гость)" : "ИИ-Ассистент SCUD Orion AI";
|
const assistantTitle = isGuest ? "Локальная нейросеть (Гость)" : "ИИ-Ассистент SCUD Orion AI";
|
||||||
const replyText = data.reply || "Пустой ответ от нейросети";
|
const replyText = data.reply || "Пустой ответ от нейросети";
|
||||||
|
|
||||||
// Генерация блока кнопок подтверждения при необходимости
|
|
||||||
let actionButtonsHtml = "";
|
let actionButtonsHtml = "";
|
||||||
const actionData = data.action_type || data.action;
|
const actionData = data.action_type || data.action;
|
||||||
|
|
||||||
@@ -168,12 +388,18 @@ async function sendMessage(e) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let interactiveWidgetHtml = "";
|
||||||
|
if (actionData && actionData.type === "TASK_INTERACTIVE_CARD" && Array.isArray(actionData.tasks)) {
|
||||||
|
interactiveWidgetHtml = renderInteractiveTaskCard(actionData.tasks);
|
||||||
|
}
|
||||||
|
|
||||||
const botMsgHtml = `
|
const botMsgHtml = `
|
||||||
<div class="bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm max-w-2xl mb-3">
|
<div class="bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm max-w-2xl mb-3">
|
||||||
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
|
<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>
|
||||||
<p class="text-slate-800 text-xs sm:text-sm whitespace-pre-wrap leading-relaxed">${escapeHtml(replyText)}</p>
|
<p class="text-slate-800 text-xs sm:text-sm whitespace-pre-wrap leading-relaxed">${escapeHtml(replyText)}</p>
|
||||||
|
${interactiveWidgetHtml}
|
||||||
${actionButtonsHtml}
|
${actionButtonsHtml}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -213,6 +439,7 @@ function escapeHtml(text) {
|
|||||||
.replace(/'/g, "'");
|
.replace(/'/g, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- [SECTION 5: DRAG & DROP AND KEYBOARD LISTENERS] --- # ANCHOR[DRAG_DROP_HANDLERS]
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const input = document.getElementById("user-input");
|
const input = document.getElementById("user-input");
|
||||||
const dropZone = document.getElementById("chat-window")?.parentElement;
|
const dropZone = document.getElementById("chat-window")?.parentElement;
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
|
/**
|
||||||
|
===============================================================================
|
||||||
|
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-карточек в боковом меню.
|
||||||
|
===============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
let currentFilter = 'ALL';
|
let currentFilter = 'ALL';
|
||||||
let allTasks = [];
|
let allTasks = [];
|
||||||
|
|
||||||
|
// --- [SECTION 1: DRAWER TOGGLE] --- # ANCHOR[DRAWER_TOGGLE]
|
||||||
function toggleDrawer() {
|
function toggleDrawer() {
|
||||||
if (typeof IS_GUEST !== 'undefined' && IS_GUEST) return;
|
if (typeof IS_GUEST !== 'undefined' && IS_GUEST) return;
|
||||||
const drawer = document.getElementById("task-drawer");
|
const drawer = document.getElementById("task-drawer");
|
||||||
@@ -18,6 +32,7 @@ function toggleDrawer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- [SECTION 2: FILTER CONTROL] --- # ANCHOR[TASKS_FILTER]
|
||||||
function setFilter(status) {
|
function setFilter(status) {
|
||||||
currentFilter = status;
|
currentFilter = status;
|
||||||
["ALL", "IN_PROGRESS", "BACKLOG", "COMPLETED"].forEach(f => {
|
["ALL", "IN_PROGRESS", "BACKLOG", "COMPLETED"].forEach(f => {
|
||||||
@@ -31,6 +46,7 @@ function setFilter(status) {
|
|||||||
renderTasks();
|
renderTasks();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- [SECTION 3: ASYNC DATA FETCH] --- # ANCHOR[TASKS_LOAD_FETCH]
|
||||||
async function loadTasks() {
|
async function loadTasks() {
|
||||||
const badge = document.getElementById("task-count-badge");
|
const badge = document.getElementById("task-count-badge");
|
||||||
const container = document.getElementById("tasks-container");
|
const container = document.getElementById("tasks-container");
|
||||||
@@ -62,7 +78,6 @@ async function loadTasks() {
|
|||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
// Гибкое определение структуры данных (массив или объект с ключом tasks)
|
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
allTasks = data;
|
allTasks = data;
|
||||||
} else if (data && Array.isArray(data.tasks)) {
|
} else if (data && Array.isArray(data.tasks)) {
|
||||||
@@ -88,6 +103,7 @@ async function loadTasks() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- [SECTION 4: DOM RENDERING] --- # ANCHOR[TASKS_RENDER_DOM]
|
||||||
function renderTasks() {
|
function renderTasks() {
|
||||||
const container = document.getElementById("tasks-container");
|
const container = document.getElementById("tasks-container");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@@ -105,13 +121,16 @@ function renderTasks() {
|
|||||||
|
|
||||||
container.innerHTML = filtered.map(t => {
|
container.innerHTML = filtered.map(t => {
|
||||||
let statusBadge = "bg-slate-100 text-slate-600 border-slate-200";
|
let statusBadge = "bg-slate-100 text-slate-600 border-slate-200";
|
||||||
|
let statusLabel = "В планах";
|
||||||
let cardBg = "bg-white";
|
let cardBg = "bg-white";
|
||||||
|
|
||||||
if (t.status === "COMPLETED") {
|
if (t.status === "COMPLETED") {
|
||||||
statusBadge = "bg-emerald-50 text-emerald-700 border-emerald-300 font-semibold";
|
statusBadge = "bg-emerald-50 text-emerald-700 border-emerald-300 font-semibold";
|
||||||
|
statusLabel = "Завершено";
|
||||||
cardBg = "bg-emerald-50/20";
|
cardBg = "bg-emerald-50/20";
|
||||||
} else if (t.status === "IN_PROGRESS") {
|
} else if (t.status === "IN_PROGRESS") {
|
||||||
statusBadge = "bg-amber-50 text-amber-700 border-amber-300 font-bold";
|
statusBadge = "bg-amber-50 text-amber-700 border-amber-300 font-bold";
|
||||||
|
statusLabel = "В работе";
|
||||||
cardBg = "bg-amber-50/20 border-amber-200";
|
cardBg = "bg-amber-50/20 border-amber-200";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +150,7 @@ function renderTasks() {
|
|||||||
<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="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>
|
<span class="text-[10px] uppercase px-1.5 py-0.5 rounded border ${priorityBadge}">${t.priority || 'MEDIUM'}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-[10px] uppercase px-2 py-0.5 rounded border ${statusBadge}">${t.status || 'BACKLOG'}</span>
|
<span class="text-[10px] uppercase px-2 py-0.5 rounded border ${statusBadge}">${statusLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-xs font-semibold text-slate-800 mb-1 leading-snug">${t.title || t.description || ''}</h3>
|
<h3 class="text-xs font-semibold text-slate-800 mb-1 leading-snug">${t.title || t.description || ''}</h3>
|
||||||
<div class="text-[10px] text-slate-400 font-mono flex items-center gap-1">
|
<div class="text-[10px] text-slate-400 font-mono flex items-center gap-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user