feat(llm): двухфазный жизненный цикл сессий, двусторонний Diff, Topic Drift Guard и актуализация документации
This commit is contained in:
+183
-56
@@ -3,15 +3,18 @@
|
||||
FILE: modules/web_api/llm/agent.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: web_api / llm (Core Agent Coordinator)
|
||||
ROLE: Оркестратор диалога, диспетчер реляционных узлов промпта и инструментов.
|
||||
ROLE: Оркестратор диалога, диспетчер реляционных узлов промпта,
|
||||
управление сессионными стейтами, Topic Drift Guard и очистка контекста.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
# --- [SECTION 1: SYSTEM PATHS & IMPORTS] --- # ANCHOR[SYS_IMPORTS]
|
||||
# =============================================================================
|
||||
# БЛОК 1: ИМПОРТЫ И ИНИЦИАЛИЗАЦИЯ СИСТЕМНЫХ МОДУЛЕЙ
|
||||
# =============================================================================
|
||||
import sys
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
|
||||
from .db.connection import get_db_connection
|
||||
@@ -44,7 +47,6 @@ from .core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_
|
||||
from .core.ollama_client import call_ollama_chat
|
||||
from .core.fast_path import handle_fast_path_intercept
|
||||
|
||||
# --- [SECTION 2: LOGGING CONFIGURATION] --- # ANCHOR[LOGGING_CONFIG]
|
||||
logger = logging.getLogger("SCUD_AGENT")
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False
|
||||
@@ -56,7 +58,9 @@ if not logger.handlers:
|
||||
logger.addHandler(handler)
|
||||
|
||||
|
||||
# --- [SECTION 3: MAIN CHAT PROCESSING PIPELINE] --- # ANCHOR[CHAT_PROCESSOR]
|
||||
# =============================================================================
|
||||
# БЛОК 2: ГЛАВНЫЙ КОНВЕЙЕР ОБРАБОТКИ СООБЩЕНИЙ ЧАТА
|
||||
# =============================================================================
|
||||
def process_chat_message(
|
||||
user_id: int,
|
||||
user_message: str,
|
||||
@@ -65,53 +69,91 @@ def process_chat_message(
|
||||
chat_history: List[Dict[str, Any]] = None,
|
||||
session_id: str = "web_session_main"
|
||||
) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||
"""Главный конвейер диалога с поддержкой реляционного редактора промпта."""
|
||||
"""
|
||||
Главная функция обработки сообщения:
|
||||
1. Очищает осиротевшие эфемерные сообщения.
|
||||
2. Перехватывает быстрые кнопки (Fast-Path).
|
||||
3. Собирает контекст и передает управление Ollama.
|
||||
4. Выполняет Tool Calls и управляет счетчиком Topic Drift.
|
||||
"""
|
||||
logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}")
|
||||
|
||||
full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message
|
||||
# 🧹 ГАРБАДЖ-КОЛЛЕКТОР: Если стейт сессии пуст, удаляем висящие эфемерные сообщения
|
||||
session_state = db_get_session_state(session_id)
|
||||
if not session_state:
|
||||
db_purge_ephemeral_messages(session_id)
|
||||
|
||||
# 1. Fast-Path перехват
|
||||
full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message
|
||||
|
||||
# Перехват нажатия кнопок («Подтвердить», «Отменить», «Завершить») без вызова LLM
|
||||
fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state)
|
||||
if fast_path_res:
|
||||
return fast_path_res
|
||||
|
||||
# 2. Сохранение пользовательского сообщения
|
||||
# Сохранение входящего сообщения оператора
|
||||
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_history = db_get_chat_history(session_id, limit=20)
|
||||
|
||||
# 3. Сборка системного промпта
|
||||
|
||||
# =========================================================================
|
||||
# БЛОК 3: ДИНАМИЧЕСКАЯ СБОРКА СИСТЕМНОГО ИНСТРУКТАЖА
|
||||
# =========================================================================
|
||||
dynamic_prompt_text = db_get_active_system_prompt()
|
||||
calendar_context = get_dynamic_calendar_context()
|
||||
user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость"
|
||||
|
||||
# Извлечение текущего состояния и счетчика отвлечений (idle_turns)
|
||||
current_state_type = session_state.get("state_type") if session_state else None
|
||||
state_data = session_state.get("data_json") or {} if session_state else {}
|
||||
if not isinstance(state_data, dict):
|
||||
state_data = {}
|
||||
idle_turns = state_data.get("idle_turns", 0)
|
||||
|
||||
active_state_context = ""
|
||||
if session_state:
|
||||
state_type = session_state.get("state_type", "GENERAL")
|
||||
active_state_context = f"\n[АКТИВНЫЙ РЕЖИМ СЕССИИ: {state_type}]\n"
|
||||
if current_state_type == "PROMPT_PREVIEW":
|
||||
active_state_context = (
|
||||
"\n[ВНИМАНИЕ: АКТИВНЫЙ РЕЖИМ ПРЕДПРОСМОТРА ПРОМПТА]\n"
|
||||
"- Сейчас открыт предпросмотр изменения системного промпта.\n"
|
||||
"- Если пользователь просит изменить, скорректировать формулировку или удалить пункт — вызови инструмент db_prompt_node_edit.\n"
|
||||
"- Если пользователь переключился на другую тему — ответь на его вопрос кратко и по делу.\n"
|
||||
)
|
||||
elif current_state_type == "PROMPT_FOLLOWUP":
|
||||
active_state_context = (
|
||||
"\n[ВНИМАНИЕ: АКТИВНА СЕССИЯ РЕДАКТИРОВАНИЯ СИСТЕМНОГО ПРОМПТА]\n"
|
||||
"- Оператор только что применил предыдущее изменение или запросил просмотр промпта.\n"
|
||||
"- Любые команды вида 'добавь X.Y', 'удали X.Y', 'измени X.Y' ОЗНАЧАЮТ ПРОДОЛЖЕНИЕ РАБОТЫ С СИСТЕМНЫМ ПРОМПТОМ -> ВЫЗЫВАЙ db_prompt_node_edit.\n"
|
||||
"- Если запрос оператора не ясен или относится к другой теме — ответь на него естественно.\n"
|
||||
)
|
||||
|
||||
system_prompt_content = (
|
||||
f"Ты — интеллектуальный ассистент SCUD Orion AI. "
|
||||
f"Ты управляешь задачами, аналитикой СКУД и системными настройками с помощью инструментов (tools).\n\n"
|
||||
f"СТРОГИЕ ПРАВИЛА СТИЛЯ:\n"
|
||||
f"- Запрещено использовать панибратские или шутливые обращения. Отвечай профессионально и строго по существу.\n\n"
|
||||
f"[ОКРУЖЕНИЕ]\n"
|
||||
f"- Пользователь: {user_info}\n"
|
||||
f"- {calendar_context}\n"
|
||||
f"{active_state_context}\n"
|
||||
f"[ПРАВИЛА ИСПОЛЬЗОВАНИЯ ИНСТРУМЕНТОВ]\n"
|
||||
f"1. Для любых изменений системного промпта ВСЕГДА вызывай db_prompt_node_edit(action=..., section_id=..., item_id=..., content=...). Абсолютно запрещено изменять промпт напрямую текстом.\n"
|
||||
f" Пример: если просят 'добавь пункт 2.9 Тестовый пункт', ты ОБЯЗАН вызвать инструмент db_prompt_node_edit с аргументами: action='ADD', section_id=2, item_id=9, content='Тестовый пункт'.\n"
|
||||
f"2. Для просмотра системного промпта ВСЕГДА вызывай db_get_system_prompt().\n"
|
||||
f"3. Для просмотра задач ВСЕГДА вызывай db_get_tasks().\n\n"
|
||||
f"[ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ]:\n{dynamic_prompt_text}"
|
||||
)
|
||||
f"Ты — интеллектуальный ассистент SCUD Orion AI. "
|
||||
f"Ты управляешь задачами, аналитикой СКУД и системными настройками с помощью инструментов (tools).\n\n"
|
||||
f"СТРОГИЕ ПРАВИЛА СТИЛЯ:\n"
|
||||
f"- Запрещено использовать панибратские или шутливые обращения. Отвечай профессионально и строго по существу.\n\n"
|
||||
f"[ОКРУЖЕНИЕ]\n"
|
||||
f"- Пользователь: {user_info}\n"
|
||||
f"- {calendar_context}\n"
|
||||
f"{active_state_context}\n"
|
||||
f"[ПРАВИЛА ИСПОЛЬЗОВАНИЯ ИНСТРУМЕНТОВ]\n"
|
||||
f"1. Для ЛЮБЫХ операций с системным промптом ТЫ ОБЯЗАН СРАЗУ ВЫЗВАТЬ db_prompt_node_edit:\n"
|
||||
f" - 'удали 2.9' / 'удалить пункт 2.9' -> ВЫЗОВ db_prompt_node_edit(action='DELETE', section_id=2, item_id=9, content='')\n"
|
||||
f" - 'добавь 3.4 Текст' / 'добавить пункт 3.4' -> ВЫЗОВ db_prompt_node_edit(action='ADD', section_id=3, item_id=4, content='Текст')\n"
|
||||
f" - 'измени 1.2 Текст' -> ВЫЗОВ db_prompt_node_edit(action='UPDATE', section_id=1, item_id=2, content='Текст')\n"
|
||||
f" КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО отвечать текстом вроде 'Удален пункт... Напишите подтверждаю'. Только Function Call!\n"
|
||||
f"2. Для просмотра системного промпта ВСЕГДА вызывай db_get_system_prompt().\n"
|
||||
f"3. Для просмотра задач ВСЕГДА вызывай db_get_tasks().\n\n"
|
||||
f"[ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ]:\n{dynamic_prompt_text}"
|
||||
)
|
||||
|
||||
user_msg_object = {"role": "user", "content": full_user_content}
|
||||
|
||||
# --- [SECTION 4: OLLAMA INFERENCE & DISPATCH] --- # ANCHOR[TOOL_ROUTER]
|
||||
|
||||
# =========================================================================
|
||||
# БЛОК 4: ВЫЗОВ НЕЙРОСЕТИ И МАРШРУТИЗАЦИЯ FUNCTION CALLING
|
||||
# =========================================================================
|
||||
try:
|
||||
# Режим Vision (OCR)
|
||||
if image_b64:
|
||||
user_msg_object["images"] = [image_b64]
|
||||
messages = [
|
||||
@@ -120,6 +162,7 @@ def process_chat_message(
|
||||
]
|
||||
msg = call_ollama_chat(messages, is_vision=True)
|
||||
else:
|
||||
# Текстовый диалог
|
||||
clean_db_history = [dict(m) for m in db_history]
|
||||
for m in clean_db_history:
|
||||
m.pop("images", None)
|
||||
@@ -130,20 +173,32 @@ def process_chat_message(
|
||||
tool_calls = msg.get("tool_calls", [])
|
||||
tool_calls = inject_tools_if_needed(user_message, raw_text_reply, tool_calls)
|
||||
|
||||
# Исполнение вызванных моделью инструментов
|
||||
if tool_calls:
|
||||
logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})")
|
||||
messages.append(msg)
|
||||
|
||||
tool_names_called = []
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# TOPIC DRIFT GUARD ДЛЯ ИНСТРУМЕНТОВ:
|
||||
# Если вызваны сторонние инструменты (снапшоты, задачи, статы)
|
||||
# — мгновенно закрываем сессию и вычищаем эфемерный контекст
|
||||
# -----------------------------------------------------------------
|
||||
is_prompt_tool = any(t["function"]["name"] in ["db_prompt_node_edit", "db_get_system_prompt", "db_get_system_prompts"] for t in tool_calls)
|
||||
if not is_prompt_tool and session_state:
|
||||
logger.info(f"Смена темы на инструмент {tool_calls[0]['function']['name']}. Закрываем сессию и очищаем эфемерный контекст.")
|
||||
db_clear_session_state(session_id)
|
||||
db_purge_ephemeral_messages(session_id)
|
||||
session_state = None
|
||||
|
||||
for tool in tool_calls:
|
||||
fn_name = tool["function"]["name"]
|
||||
fn_args = tool["function"].get("arguments", {})
|
||||
tool_names_called.append(fn_name)
|
||||
logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}")
|
||||
tool_result_content = ""
|
||||
action_cfg = db_get_tool_action(fn_name)
|
||||
|
||||
# --- 4.1. Модуль задач ---
|
||||
if fn_name == "db_get_tasks":
|
||||
raw_tasks = db_get_tasks(user_id)
|
||||
reply_text = "Вот интерактивный список ваших текущих задач:"
|
||||
@@ -153,47 +208,88 @@ def process_chat_message(
|
||||
"tasks": raw_tasks
|
||||
}
|
||||
|
||||
# --- 4.2. Прямой просмотр системного промпта из SQLite ---
|
||||
elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]:
|
||||
active_prompt = db_get_active_system_prompt()
|
||||
reply_text = f"Актуальный системный промпт:\n\n{active_prompt}"
|
||||
|
||||
# Переводим сессию в PROMPT_FOLLOWUP для отслеживания Topic Drift
|
||||
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0})
|
||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||||
return reply_text, db_get_chat_history(session_id), None
|
||||
|
||||
# --- 4.3. Реляционное изменение промпта (ADD / UPDATE / DELETE) ---
|
||||
elif fn_name == "db_prompt_node_edit":
|
||||
action = fn_args.get("action", "ADD").upper()
|
||||
sec_id = int(fn_args.get("section_id", 2))
|
||||
itm_id = int(fn_args.get("item_id", 1))
|
||||
content = fn_args.get("content", "").strip()
|
||||
baseline_prompt = db_get_active_system_prompt()
|
||||
|
||||
with get_db_connection() as temp_conn:
|
||||
temp_cursor = temp_conn.cursor()
|
||||
temp_cursor.execute("SELECT section_id, item_id, content FROM system_prompt_nodes WHERE prompt_name = 'main_agent' AND is_active = 1 ORDER BY section_id, item_id")
|
||||
temp_cursor.execute("""
|
||||
SELECT section_id, item_id, content
|
||||
FROM system_prompt_nodes
|
||||
WHERE prompt_name = 'main_agent' AND is_active = 1
|
||||
ORDER BY section_id, item_id
|
||||
""")
|
||||
existing_nodes = temp_cursor.fetchall()
|
||||
|
||||
nodes_dict = {(sec, itm): txt for sec, itm, txt in existing_nodes}
|
||||
|
||||
if action == "DELETE":
|
||||
nodes_dict.pop((sec_id, itm_id), None)
|
||||
else:
|
||||
nodes_dict[(sec_id, itm_id)] = content
|
||||
|
||||
virtual_lines = []
|
||||
if action == "DELETE":
|
||||
nodes_dict_for_draft = {k: v for k, v in nodes_dict.items() if k != (sec_id, itm_id)}
|
||||
else:
|
||||
nodes_dict_for_draft = dict(nodes_dict)
|
||||
nodes_dict_for_draft[(sec_id, itm_id)] = content
|
||||
|
||||
# Формирование чистого текста для редактора
|
||||
draft_lines = []
|
||||
curr_sec = None
|
||||
for (s_id, i_id), txt in sorted(nodes_dict.items()):
|
||||
for (s_id, i_id), txt in sorted(nodes_dict_for_draft.items()):
|
||||
if i_id == 0:
|
||||
if curr_sec is not None:
|
||||
virtual_lines.append("")
|
||||
virtual_lines.append(f"{s_id}. {txt}")
|
||||
draft_lines.append("")
|
||||
draft_lines.append(f"{s_id}. {txt}")
|
||||
curr_sec = s_id
|
||||
else:
|
||||
draft_lines.append(f" {s_id}.{i_id}. {txt}")
|
||||
merged_prompt = "\n".join(draft_lines)
|
||||
|
||||
# Формирование HTML Diff с подсветкой
|
||||
diff_lines = []
|
||||
curr_sec = None
|
||||
display_nodes = dict(nodes_dict)
|
||||
if action != "DELETE":
|
||||
display_nodes[(sec_id, itm_id)] = content
|
||||
|
||||
for (s_id, i_id), txt in sorted(display_nodes.items()):
|
||||
if i_id == 0:
|
||||
if curr_sec is not None:
|
||||
diff_lines.append("")
|
||||
diff_lines.append(f"{s_id}. {txt}")
|
||||
curr_sec = s_id
|
||||
else:
|
||||
line_str = f" {s_id}.{i_id}. {txt}"
|
||||
if s_id == sec_id and i_id == itm_id:
|
||||
line_str = f'<span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-200">{s_id}.{i_id}. {txt}</span>'
|
||||
virtual_lines.append(line_str)
|
||||
if action == "DELETE":
|
||||
line_str = f' <span class="line-through text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300 opacity-80">{s_id}.{i_id}. {txt} [УДАЛЕНИЕ]</span>'
|
||||
else:
|
||||
line_str = f' <span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300">{s_id}.{i_id}. {txt}</span>'
|
||||
else:
|
||||
line_str = f" {s_id}.{i_id}. {txt}"
|
||||
diff_lines.append(line_str)
|
||||
|
||||
merged_prompt = "\n".join([re.sub(r'<[^>]+>', '', l) for l in virtual_lines])
|
||||
diff_html = "\n".join(virtual_lines)
|
||||
diff_html = "\n".join(diff_lines)
|
||||
|
||||
# Фиксация предпросмотра в session_states
|
||||
db_set_session_state(session_id, "PROMPT_PREVIEW", {
|
||||
"draft_text": merged_prompt,
|
||||
"action": action,
|
||||
"section_id": sec_id,
|
||||
"item_id": itm_id,
|
||||
"content": content
|
||||
"content": content,
|
||||
"idle_turns": 0
|
||||
})
|
||||
|
||||
with get_db_connection() as conn_fix:
|
||||
@@ -214,6 +310,7 @@ def process_chat_message(
|
||||
return preview_reply, db_get_chat_history(session_id), {
|
||||
"type": "PROMPT_PREVIEW",
|
||||
"raw_draft": merged_prompt,
|
||||
"baseline_prompt": baseline_prompt,
|
||||
"buttons": [
|
||||
{"label": "Подтвердить", "value": "подтверждаю", "style": "primary"},
|
||||
{"label": "Отменить", "value": "отмена", "style": "danger"},
|
||||
@@ -221,9 +318,7 @@ def process_chat_message(
|
||||
]
|
||||
}
|
||||
|
||||
elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]:
|
||||
tool_result_content = json.dumps({"system_prompt": db_get_active_system_prompt()}, ensure_ascii=False)
|
||||
|
||||
# --- 4.4. Сервисные инструменты ---
|
||||
elif fn_name == "db_get_snapshots":
|
||||
snapshots_res = db_get_snapshots(session_id=session_id, date_str=fn_args.get("date_str"), original_user_message=user_message)
|
||||
tool_result_content = json.dumps(snapshots_res, ensure_ascii=False)
|
||||
@@ -237,9 +332,6 @@ def process_chat_message(
|
||||
elif fn_name == "db_get_anomalies":
|
||||
tool_result_content = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False)
|
||||
|
||||
elif fn_name == "db_get_session_states":
|
||||
tool_result_content = json.dumps(db_get_session_states(), ensure_ascii=False)
|
||||
|
||||
elif fn_name == "db_get_rules":
|
||||
tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False)
|
||||
|
||||
@@ -264,6 +356,7 @@ def process_chat_message(
|
||||
|
||||
messages.append({"role": "tool", "content": tool_result_content})
|
||||
|
||||
# Интерпретация результатов вызова инструментов
|
||||
sec_msg = call_ollama_chat(messages, is_vision=False)
|
||||
raw_content = sec_msg.get("content", "").strip().replace("**", "").replace("*", "")
|
||||
final_content = clean_raw_tool_tags(clean_output(raw_content))
|
||||
@@ -273,13 +366,47 @@ def process_chat_message(
|
||||
db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_output_ephemeral)
|
||||
return final_content, db_get_chat_history(session_id), None
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# БЛОК 5: ОБЫЧНЫЙ ТЕКСТОВЫЙ ОТВЕТ И СЕМАНТИЧЕСКИЙ TOPIC DRIFT GUARD
|
||||
# =====================================================================
|
||||
raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "")
|
||||
final_reply = clean_raw_tool_tags(clean_output(raw_str)) or "Запрос обработан."
|
||||
action_payload = None
|
||||
|
||||
db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0)
|
||||
return final_reply, db_get_chat_history(session_id), None
|
||||
# Обработка отвлечений оператора при активной сессии настройки/просмотра
|
||||
if session_state and session_state.get("state_type") in ["PROMPT_FOLLOWUP", "PROMPT_PREVIEW"]:
|
||||
idle_turns += 1
|
||||
logger.info(f"Topic Drift: активна сессия {session_state.get('state_type')}, шагов отвлечения: {idle_turns}/3")
|
||||
|
||||
# Порог отвлечений превышен (> 2 шагов после напоминания) -> Полная зачистка
|
||||
if idle_turns > 3:
|
||||
db_clear_session_state(session_id)
|
||||
deleted_count = db_purge_ephemeral_messages(session_id)
|
||||
logger.info(f"Topic Drift Guard TTL: сессия закрыта по таймауту, очищено {deleted_count} сообщений.")
|
||||
action_payload = None
|
||||
session_state = None
|
||||
elif idle_turns == 3:
|
||||
# На 3-м сообщении стороннего диалога вежливо спрашиваем оператора
|
||||
tool_action = db_get_tool_action("db_confirm_prompt_preview")
|
||||
guard_question = tool_action.get("follow_up_question", "Желаете продолжить работу с системным промптом?") if tool_action else "Желаете продолжить работу с системным промптом?"
|
||||
buttons = tool_action.get("buttons", []) if tool_action else []
|
||||
|
||||
final_reply += f"\n\n💡 *Напоминание:* {guard_question}"
|
||||
action_payload = {
|
||||
"type": "PROMPT_FOLLOWUP",
|
||||
"buttons": buttons
|
||||
}
|
||||
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": idle_turns})
|
||||
else:
|
||||
# Шаги 1 и 2: продолжаем обычный диалог, инкрементируя счетчик
|
||||
db_set_session_state(session_id, session_state.get("state_type"), {"idle_turns": idle_turns})
|
||||
|
||||
db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=(1 if session_state else 0))
|
||||
return final_reply, db_get_chat_history(session_id), action_payload
|
||||
|
||||
# Обработка исключений
|
||||
except Exception as ex:
|
||||
logger.exception(f"Непредвиденная ошибка: {ex}")
|
||||
logger.exception(f"Непредвиденная ошибка агента: {ex}")
|
||||
error_reply = f"Внутренняя ошибка сервера: {ex}"
|
||||
return error_reply, db_get_chat_history(session_id), None
|
||||
@@ -3,17 +3,18 @@
|
||||
FILE: modules/web_api/llm/core/fast_path.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: web_api / llm / core
|
||||
ROLE: Детерминированный перехват подтверждения, отмены и мгновенной очистки
|
||||
эфемерного контекста без повторного вывода кнопок-вилок.
|
||||
ROLE: Двухфазный жизненный цикл сессии (PROMPT_PREVIEW -> PROMPT_FOLLOWUP),
|
||||
атомарная фиксация узлов и мягкая зачистка эфемерного контекста.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
# ANCHOR[FAST_PATH_IMPORTS]
|
||||
import logging
|
||||
from typing import Dict, Any, Tuple, Optional
|
||||
|
||||
from llm.db_tools import (
|
||||
db_apply_prompt_node_action,
|
||||
db_add_system_prompt,
|
||||
db_set_session_state,
|
||||
db_clear_session_state,
|
||||
db_purge_ephemeral_messages,
|
||||
db_save_chat_message,
|
||||
@@ -23,47 +24,83 @@ from llm.db_tools import (
|
||||
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]]]]:
|
||||
"""
|
||||
1. Подтвердить -> запись в БД + db_purge_ephemeral_messages + инфо-текст.
|
||||
2. Отменить -> сброс стейта + db_purge_ephemeral_messages + инфо-текст.
|
||||
"""
|
||||
if not session_state or session_state.get("state_type") != "PROMPT_PREVIEW":
|
||||
"""Обрабатывает команды подтверждения, отмены и завершения сессии."""
|
||||
if not session_state:
|
||||
return None
|
||||
|
||||
state_type = session_state.get("state_type")
|
||||
user_msg_clean = user_message.lower().strip(" .!?:;")
|
||||
|
||||
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", "")
|
||||
# =========================================================================
|
||||
# ФАЗА 1: ОБРАБОТКА В СОСТОЯНИИ PROMPT_PREVIEW
|
||||
# =========================================================================
|
||||
if state_type == "PROMPT_PREVIEW":
|
||||
state_data = session_state.get("data_json") or {}
|
||||
if not isinstance(state_data, dict):
|
||||
state_data = {}
|
||||
|
||||
# 1. ПОДТВЕРДИТЬ ИЗМЕНЕНИЯ
|
||||
if user_msg_clean in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]:
|
||||
db_add_system_prompt("main_agent", draft_text)
|
||||
db_clear_session_state(session_id)
|
||||
deleted_count = db_purge_ephemeral_messages(session_id)
|
||||
logger.info(f"Системный промпт сохранен в БД. Удалено эфемерных сообщений: {deleted_count}")
|
||||
draft_text = state_data.get("draft_text", "")
|
||||
action = state_data.get("action")
|
||||
sec_id = state_data.get("section_id")
|
||||
itm_id = state_data.get("item_id")
|
||||
content = state_data.get("content", "")
|
||||
|
||||
reply_text = "Системный промпт успешно сохранен и применен в базе данных."
|
||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0)
|
||||
return reply_text, db_get_chat_history(session_id), None
|
||||
# 1. ПОДТВЕРДИТЬ ИЗМЕНЕНИЯ -> ПЕРЕХОД В PROMPT_FOLLOWUP
|
||||
if user_msg_clean in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]:
|
||||
if action == "MANUAL_EDIT" or not action or sec_id is None:
|
||||
if draft_text:
|
||||
db_add_system_prompt("main_agent", draft_text)
|
||||
else:
|
||||
db_apply_prompt_node_action(action=action, section_id=sec_id, item_id=itm_id, content=content)
|
||||
|
||||
# 2. ОТМЕНИТЬ ИЗМЕНЕНИЯ
|
||||
elif user_msg_clean in ["отмена", "отменить", "отклонить", "назад", "стоп"]:
|
||||
db_clear_session_state(session_id)
|
||||
deleted_count = db_purge_ephemeral_messages(session_id)
|
||||
logger.info(f"Изменения промпта отменены. Удалено эфемерных сообщений: {deleted_count}")
|
||||
logger.info("Системный промпт применен в БД. Переход в PROMPT_FOLLOWUP.")
|
||||
|
||||
# Переводим сессию в режим ожидания продолжения/завершения
|
||||
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {})
|
||||
|
||||
reply_text = "Изменения системного промпта отменены. Контекст диалога чист."
|
||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0)
|
||||
return reply_text, db_get_chat_history(session_id), None
|
||||
reply_text = "Системный промпт успешно сохранен и применен в базе данных. Желаете продолжить работу с системным промптом или завершить?"
|
||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||||
|
||||
action_payload = {
|
||||
"type": "PROMPT_FOLLOWUP",
|
||||
"buttons": [
|
||||
{"label": "✨ Показать обновленный промпт", "value": "покажи системный промпт", "style": "secondary"},
|
||||
{"label": "🏁 Завершить работу", "value": "завершить работу", "style": "primary"}
|
||||
]
|
||||
}
|
||||
return reply_text, db_get_chat_history(session_id), action_payload
|
||||
|
||||
# 2. ОТМЕНА В МОМЕНТ ПРЕВЬЮ -> ПОЛНАЯ ЗАЧИСТКА
|
||||
elif 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: ОБРАБОТКА В СОСТОЯНИИ PROMPT_FOLLOWUP
|
||||
# =========================================================================
|
||||
elif state_type == "PROMPT_FOLLOWUP":
|
||||
# Явный сигнал завершения работы
|
||||
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
|
||||
|
||||
return None
|
||||
@@ -1,8 +1,12 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: modules/web_api/llm/core/tool_injector.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: web_api / llm / core
|
||||
ROLE: Санитарная очистка сырых артефактов и тегов из ответов LLM.
|
||||
===============================================================================
|
||||
"""
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
from typing import List, Dict, Any
|
||||
|
||||
@@ -10,6 +14,7 @@ logger = logging.getLogger("TOOL_INJECTOR")
|
||||
|
||||
|
||||
def clean_raw_tool_tags(text: str) -> str:
|
||||
"""Удаляет сырые теги вызова инструментов, если модель случайно вывела их в текст."""
|
||||
if not text:
|
||||
return ""
|
||||
text = re.sub(r'\{"name":\s*"db_[^}]+\}\s*(</tool_call>)?', '', text)
|
||||
@@ -20,10 +25,10 @@ def clean_raw_tool_tags(text: str) -> str:
|
||||
|
||||
|
||||
def clean_output(text: str) -> str:
|
||||
"""Удаляет слова-паразиты и склейки в начале ответа."""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
# Фильтрация паразитных артефактов токенизатора Qwen
|
||||
artifacts = [
|
||||
"почемучка", "почемучка,", "почемучка!", "почемучка?",
|
||||
"почемучто", "почто", "почему что", "почему-то",
|
||||
@@ -39,15 +44,7 @@ def clean_output(text: str) -> str:
|
||||
|
||||
|
||||
def inject_tools_if_needed(user_message: str, raw_text_content: str, tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
if tool_calls:
|
||||
return tool_calls
|
||||
if '{"name":' in raw_text_content or '<tool_call>' in raw_text_content:
|
||||
try:
|
||||
match = re.search(r'\{"name":\s*"([^"]+)",\s*"(?:params|arguments|properties)":\s*(\{.*?\})\}', raw_text_content)
|
||||
if match:
|
||||
fn_name = match.group(1)
|
||||
fn_args = json.loads(match.group(2))
|
||||
return [{"function": {"name": fn_name, "arguments": fn_args}}]
|
||||
except Exception as parse_err:
|
||||
logger.debug(f"Ошибка парсинга сырого tool call: {parse_err}")
|
||||
"""
|
||||
Проходной фильтр: все решения о вызове инструментов принимает исключительно LLM.
|
||||
"""
|
||||
return tool_calls
|
||||
@@ -7,6 +7,7 @@ ROLE: Реляционное управление системным промп
|
||||
базой знаний, реестром действий и сессионными стейтами.
|
||||
===============================================================================
|
||||
"""
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
@@ -239,7 +240,7 @@ def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]:
|
||||
return {"status": "success", "count": len(rows), "reference_items": [dict(r) for r in rows]}
|
||||
|
||||
def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]:
|
||||
"""Мост обратной совместимости: парсит текст промпта и сохраняет по узлам в БД."""
|
||||
"""Надежный парсер: восстанавливает разделы и пункты с авто-выравниванием отступов."""
|
||||
try:
|
||||
init_prompt_nodes_table()
|
||||
with get_db_connection() as conn:
|
||||
@@ -249,30 +250,28 @@ def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]:
|
||||
current_sec = 1
|
||||
current_itm = 0
|
||||
|
||||
for line in prompt_text.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
for raw_line in prompt_text.splitlines():
|
||||
clean_line = re.sub(r'<[^>]+>', '', raw_line).strip()
|
||||
if not clean_line:
|
||||
continue
|
||||
|
||||
# Проверяем заголовок раздела (например, "1. РОЛЬ И ЗАДАЧИ...")
|
||||
parts = stripped.split(".", 1)
|
||||
if len(parts) == 2 and parts[0].strip().isdigit():
|
||||
sec_num = int(parts[0].strip())
|
||||
rest = parts[1].strip()
|
||||
|
||||
# Проверяем, это подпункт (например, "1.1. Текст") или заголовок раздела
|
||||
sub_parts = rest.split(".", 1)
|
||||
if len(sub_parts) == 2 and sub_parts[0].strip().isdigit():
|
||||
current_sec = sec_num
|
||||
current_itm = int(sub_parts[0].strip())
|
||||
content = sub_parts[1].strip()
|
||||
else:
|
||||
current_sec = sec_num
|
||||
current_itm = 0
|
||||
content = rest
|
||||
|
||||
# 1. Проверяем подпункт (например: "1.8. Текст", "1.8 Текст", "1. 8 Текст")
|
||||
sub_match = re.match(r'^(\d+)[\.\s]+(\d+)[\.\s\:\-]+(.*)$', clean_line)
|
||||
# 2. Проверяем заголовок раздела (например: "1. РОЛЬ И ЗАДАЧИ", "1 РОЛЬ И ЗАДАЧИ")
|
||||
sec_match = re.match(r'^(\d+)[\.\s\:\-]+(.*)$', clean_line)
|
||||
|
||||
if sub_match:
|
||||
current_sec = int(sub_match.group(1))
|
||||
current_itm = int(sub_match.group(2))
|
||||
content = sub_match.group(3).strip()
|
||||
elif sec_match and not any(c.islower() for c in sec_match.group(2)[:15]):
|
||||
# Заголовок раздела (обычно капсом)
|
||||
current_sec = int(sec_match.group(1))
|
||||
current_itm = 0
|
||||
content = sec_match.group(2).strip()
|
||||
else:
|
||||
current_itm += 1
|
||||
content = stripped
|
||||
content = clean_line
|
||||
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active)
|
||||
|
||||
@@ -1,4 +1,39 @@
|
||||
TOOLS_SCHEMA = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_prompt_node_edit",
|
||||
"description": (
|
||||
"ЕДИНСТВЕННЫЙ инструмент для добавления, изменения или УДАЛЕНИЯ пунктов системного промпта. "
|
||||
"Вызывай ВСЕГДА при фразах 'удали X.Y', 'удалить пункт X.Y', 'добавь X.Y', 'измени X.Y'. "
|
||||
"При удалении: action='DELETE', section_id=X, item_id=Y, content=''. "
|
||||
"Запрещено отвечать текстовым подтверждением без вызова этой функции!"
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["ADD", "UPDATE", "DELETE"],
|
||||
"description": "ADD (добавить), UPDATE (изменить), DELETE (удалить)"
|
||||
},
|
||||
"section_id": {
|
||||
"type": "integer",
|
||||
"description": "Номер раздела (например: 1, 2, 3)"
|
||||
},
|
||||
"item_id": {
|
||||
"type": "integer",
|
||||
"description": "Номер пункта (например: 4, 8, 9)"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Текст пункта (для DELETE передается пустая строка)"
|
||||
}
|
||||
},
|
||||
"required": ["action", "section_id", "item_id"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -169,39 +204,5 @@ TOOLS_SCHEMA = [
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_prompt_node_edit",
|
||||
"description": (
|
||||
"Сформировать предпросмотр изменения системного промпта через реляционные узлы. "
|
||||
"Вызывай при любых запросах на добавление, изменение или удаление пунктов. "
|
||||
"Раздел и пункт передавай числами (например, для пункта '2.9' -> section_id=2, item_id=9)."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["ADD", "UPDATE", "DELETE"],
|
||||
"description": "Действие: ADD (добавить пункт), UPDATE (изменить пункт), DELETE (удалить пункт)"
|
||||
},
|
||||
"section_id": {
|
||||
"type": "integer",
|
||||
"description": "Номер раздела (например, 2)"
|
||||
},
|
||||
"item_id": {
|
||||
"type": "integer",
|
||||
"description": "Номер пункта внутри раздела (например, 9)"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Текст формулировки пункта (для ADD и UPDATE). Без префикса номера!"
|
||||
}
|
||||
},
|
||||
"required": ["action", "section_id", "item_id"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -82,5 +82,12 @@ def update_draft_endpoint(
|
||||
if not state or state.get("state_type") != "PROMPT_PREVIEW":
|
||||
raise HTTPException(status_code=400, detail="Нет активного превью для редактирования")
|
||||
|
||||
db_set_session_state(req.session_id, "PROMPT_PREVIEW", {"draft_text": req.draft_text.strip()})
|
||||
# Сохраняем чистый текст с пометкой MANUAL_EDIT
|
||||
db_set_session_state(req.session_id, "PROMPT_PREVIEW", {
|
||||
"draft_text": req.draft_text.strip(),
|
||||
"action": "MANUAL_EDIT",
|
||||
"section_id": None,
|
||||
"item_id": None,
|
||||
"content": ""
|
||||
})
|
||||
return {"status": "success", "message": "Черновик успешно обновлен в сессии"}
|
||||
@@ -1,47 +0,0 @@
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
# Автопоиск файла базы данных в проекте
|
||||
db_path = '/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db' if os.path.exists('/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db') else 'scud_orion_ai.db'
|
||||
|
||||
print("=" * 80)
|
||||
print(f"🔍 ДИАГНОСТИКА СУБД SQLITE: {db_path}")
|
||||
print("=" * 80)
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
print(f"❌ Файл базы данных {db_path} не найден!")
|
||||
exit(1)
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 1. Список всех таблиц и колонок
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||
tables = [t[0] for t in cursor.fetchall()]
|
||||
|
||||
print("\n📋 СТРУКТУРА ТАБЛИЦ И КОЛИЧЕСТВО ЗАПИСЕЙ:")
|
||||
print("-" * 80)
|
||||
for t_name in tables:
|
||||
cursor.execute(f"PRAGMA table_info({t_name})")
|
||||
cols = [c[1] for c in cursor.fetchall()]
|
||||
|
||||
cursor.execute(f"SELECT COUNT(*) FROM {t_name}")
|
||||
count = cursor.fetchone()[0]
|
||||
|
||||
print(f"• [{t_name:<20}] — {count:>6} строк | Колонки: {cols}")
|
||||
|
||||
# 2. Просмотр правил Базы Знаний
|
||||
if 'ai_knowledge_base' in tables:
|
||||
print("\n" + "=" * 80)
|
||||
print("🧠 АКТУАЛЬНЫЕ ПРАВИЛА БАЗЫ ЗНАНИЙ (ai_knowledge_base):")
|
||||
print("=" * 80)
|
||||
cursor.execute("SELECT id, rule_text, added_by FROM ai_knowledge_base ORDER BY id ASC")
|
||||
rules = cursor.fetchall()
|
||||
if not rules:
|
||||
print("Таблица ai_knowledge_base пуста.")
|
||||
else:
|
||||
for r_id, r_text, r_author in rules:
|
||||
print(f" {r_id}. [{r_author}] {r_text}\n")
|
||||
|
||||
conn.close()
|
||||
print("=" * 80)
|
||||
@@ -1,23 +0,0 @@
|
||||
import os
|
||||
|
||||
print("=" * 80)
|
||||
print("📂 ТЕКУЩЕЕ СОСТОЯНИЕ ФАЙЛОВ ПРОЕКТА (scud_orion_context)")
|
||||
print("=" * 80)
|
||||
|
||||
total_files = 0
|
||||
total_size = 0
|
||||
|
||||
for root, dirs, files in os.walk('.'):
|
||||
# Исключаем служебные каталоги
|
||||
dirs[:] = [d for d in dirs if d not in ['.git', '__pycache__', 'venv', '.venv', 'extracted_project']]
|
||||
|
||||
for f in files:
|
||||
p = os.path.join(root, f)
|
||||
size = os.path.getsize(p)
|
||||
total_files += 1
|
||||
total_size += size
|
||||
print(f"{p:<55} ({size:>10,} bytes)".replace(',', ' '))
|
||||
|
||||
print("-" * 80)
|
||||
print(f"ИТОГО: файлов: {total_files} | Общий объем: {total_size / (1024 * 1024):.2f} MB")
|
||||
print("=" * 80)
|
||||
@@ -1,31 +0,0 @@
|
||||
import os
|
||||
|
||||
OUTPUT_SNAPSHOT = "api_code_snapshot.md"
|
||||
|
||||
# Расширения файлов для включения в снимок
|
||||
ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini', '.js', '.html', '.css'}
|
||||
EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'}
|
||||
EXCLUDE_FILES = {OUTPUT_SNAPSHOT, 'scud_context_api.tar.gz', 'context_memory.db'}
|
||||
|
||||
print(f"🔄 Сборка полного контекстного слепка проекта в {OUTPUT_SNAPSHOT}...")
|
||||
|
||||
with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out:
|
||||
out.write("# 📦 ПОЛНЫЙ ИСХОДНЫЙ КОД И КОНФИГУРАЦИЯ ПРОЕКТА scud_context_api\n\n")
|
||||
|
||||
for root, dirs, files in os.walk('.'):
|
||||
dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS]
|
||||
|
||||
for file in sorted(files):
|
||||
ext = os.path.splitext(file)[1].lower()
|
||||
if ext in ALLOWED_EXTENSIONS and file not in EXCLUDE_FILES:
|
||||
filepath = os.path.join(root, file)
|
||||
out.write(f"## File: `{filepath}`\n")
|
||||
out.write("```" + (ext.replace('.', '') if ext != '.md' else '') + "\n")
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
|
||||
out.write(f.read())
|
||||
except Exception as e:
|
||||
out.write(f"// Ошибка чтения файла: {e}\n")
|
||||
out.write("\n```\n\n")
|
||||
|
||||
print(f"✓ Успешно создан слепок проекта: {OUTPUT_SNAPSHOT} ({os.path.getsize(OUTPUT_SNAPSHOT):,} bytes)")
|
||||
@@ -1,568 +0,0 @@
|
||||
/**
|
||||
===============================================================================
|
||||
FILE: modules/web_api/static/js/chat.js
|
||||
ROLE: Клиентский интерфейс диалога, Drag-and-Drop вложений, генерация динамических
|
||||
кнопок подтверждений и блокировка ввода при активном действии.
|
||||
===============================================================================
|
||||
*/
|
||||
|
||||
function updateInputHeight(el) {
|
||||
if (!el) return;
|
||||
el.style.height = "24px";
|
||||
const newHeight = Math.min(el.scrollHeight, 120);
|
||||
el.style.height = newHeight + "px";
|
||||
}
|
||||
|
||||
let selectedFile = null;
|
||||
|
||||
function handleFileSelect(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
if (file.size > 15 * 1024 * 1024) {
|
||||
alert("Файл слишком большой. Максимальный размер: 15 МБ");
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
selectedFile = file;
|
||||
const fileNameEl = document.getElementById("file-name-display");
|
||||
const fileSizeEl = document.getElementById("file-size-display");
|
||||
const previewContainer = document.getElementById("file-preview-container");
|
||||
|
||||
if (fileNameEl) fileNameEl.innerText = file.name;
|
||||
if (fileSizeEl) fileSizeEl.innerText = `(${(file.size / 1024).toFixed(1)} KB)`;
|
||||
if (previewContainer) previewContainer.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function clearAttachedFile() {
|
||||
selectedFile = null;
|
||||
const fileInput = document.getElementById("file-input");
|
||||
const previewContainer = document.getElementById("file-preview-container");
|
||||
if (fileInput) fileInput.value = "";
|
||||
if (previewContainer) previewContainer.classList.add("hidden");
|
||||
}
|
||||
|
||||
function setInputLocked(isLocked) {
|
||||
const input = document.getElementById("user-input");
|
||||
const sendBtn = document.getElementById("send-btn");
|
||||
if (input) {
|
||||
input.disabled = isLocked;
|
||||
if (isLocked) {
|
||||
input.placeholder = "Выберите действие с помощью кнопок выше...";
|
||||
input.classList.add("cursor-not-allowed", "opacity-60");
|
||||
} else {
|
||||
input.placeholder = "Команда, вопрос или перетащите файл сюда...";
|
||||
input.classList.remove("cursor-not-allowed", "opacity-60");
|
||||
}
|
||||
}
|
||||
if (sendBtn) {
|
||||
sendBtn.disabled = isLocked;
|
||||
if (isLocked) {
|
||||
sendBtn.classList.add("opacity-40", "cursor-not-allowed");
|
||||
} else {
|
||||
sendBtn.classList.remove("opacity-40", "cursor-not-allowed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function disableAllActionButtons() {
|
||||
const allBtnContainers = document.querySelectorAll(".action-buttons-container");
|
||||
allBtnContainers.forEach(container => {
|
||||
container.querySelectorAll("button").forEach(btn => {
|
||||
btn.disabled = true;
|
||||
btn.classList.add("opacity-40", "cursor-not-allowed");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleActionButtonClick(text) {
|
||||
disableAllActionButtons();
|
||||
setInputLocked(false);
|
||||
const input = document.getElementById("user-input");
|
||||
if (input) {
|
||||
input.value = text;
|
||||
sendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// --- [INTERACTIVE TASK WIDGET] ---
|
||||
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 = "";
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// --- [CHAT SEND PIPELINE] ---
|
||||
async function sendMessage(e) {
|
||||
if (e && e.preventDefault) e.preventDefault();
|
||||
|
||||
const input = document.getElementById("user-input");
|
||||
const chatWindow = document.getElementById("chat-window");
|
||||
const sendBtn = document.getElementById("send-btn");
|
||||
|
||||
if (!input || !chatWindow) return;
|
||||
const text = input.value.trim();
|
||||
|
||||
if (!text && !selectedFile) return;
|
||||
|
||||
disableAllActionButtons();
|
||||
|
||||
let userDisplayHtml = escapeHtml(text);
|
||||
if (selectedFile) {
|
||||
userDisplayHtml = `<div class="font-bold border-b border-indigo-400/40 pb-1 mb-1 text-[11px] flex items-center gap-1.5">
|
||||
<i class="fa-solid fa-file"></i> ${escapeHtml(selectedFile.name)}
|
||||
</div>` + userDisplayHtml;
|
||||
}
|
||||
|
||||
const userMsgHtml = `
|
||||
<div class="flex justify-end mb-3">
|
||||
<div class="bg-indigo-600 text-white rounded-2xl px-4 py-2.5 max-w-2xl text-xs sm:text-sm shadow-sm">
|
||||
${userDisplayHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
chatWindow.insertAdjacentHTML("beforeend", userMsgHtml);
|
||||
|
||||
input.value = "";
|
||||
updateInputHeight(input);
|
||||
chatWindow.scrollTop = chatWindow.scrollHeight;
|
||||
|
||||
if (sendBtn) {
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.classList.add("opacity-50");
|
||||
}
|
||||
|
||||
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
|
||||
const isGuest = typeof IS_GUEST !== 'undefined' ? IS_GUEST : (localStorage.getItem("scud_is_guest") === "true");
|
||||
|
||||
const endpoint = isGuest ? "/api/v1/chat/guest" : "/api/v1/chat";
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("session_id", "web_session_main");
|
||||
formData.append("message", text || "Проанализируй прикрепленный файл");
|
||||
|
||||
if (selectedFile instanceof File) {
|
||||
formData.append("file", selectedFile, selectedFile.name);
|
||||
}
|
||||
|
||||
const headers = {};
|
||||
if (!isGuest && token) {
|
||||
headers["Authorization"] = "Bearer " + token;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: headers,
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (res.status === 401 && !isGuest) {
|
||||
if (typeof logout === 'function') logout();
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const assistantTitle = isGuest ? "Локальная нейросеть (Гость)" : "ИИ-Ассистент SCUD Orion AI";
|
||||
const replyText = data.reply || "Пустой ответ от нейросети";
|
||||
|
||||
let actionButtonsHtml = "";
|
||||
const actionData = data.action_type || data.action;
|
||||
|
||||
if (actionData && actionData.buttons && actionData.buttons.length > 0) {
|
||||
const buttonsMarkup = actionData.buttons.map(btn => {
|
||||
let btnClasses = "bg-slate-100 hover:bg-slate-200 active:bg-slate-300 text-slate-700 border border-slate-300";
|
||||
let iconMarkup = '<i class="fa-solid fa-arrow-right text-[10px] opacity-60"></i>';
|
||||
|
||||
const labelLower = (btn.label || "").toLowerCase();
|
||||
const isPrimary = btn.style === "primary" || labelLower.includes("подтверд") || labelLower.startsWith("да");
|
||||
const isDanger = btn.style === "danger" || labelLower.includes("отмен") || labelLower.startsWith("нет") || labelLower.includes("законч") || labelLower.includes("заверш");
|
||||
|
||||
if (isPrimary) {
|
||||
btnClasses = "bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white shadow-sm";
|
||||
iconMarkup = '<i class="fa-solid fa-check"></i>';
|
||||
} else if (isDanger) {
|
||||
btnClasses = "bg-rose-50 hover:bg-rose-100 active:bg-rose-200 text-rose-700 border border-rose-300";
|
||||
iconMarkup = '<i class="fa-solid fa-xmark"></i>';
|
||||
}
|
||||
|
||||
return `
|
||||
<button type="button" onclick="handleActionButtonClick('${escapeHtml(btn.value)}')"
|
||||
class="${btnClasses} font-semibold px-3 py-1.5 rounded-xl text-xs flex items-center gap-1.5 transition cursor-pointer">
|
||||
${iconMarkup}
|
||||
<span>${escapeHtml(btn.label)}</span>
|
||||
</button>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
actionButtonsHtml = `
|
||||
<div class="action-buttons-container flex flex-wrap items-center gap-2 mt-3 pt-2.5 border-t border-slate-100">
|
||||
${buttonsMarkup}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Если выведены кнопки подтверждения превью — блокируем ввод с клавиатуры
|
||||
if (actionData.type === "PROMPT_PREVIEW") {
|
||||
setInputLocked(true);
|
||||
} else {
|
||||
setInputLocked(false);
|
||||
}
|
||||
} else {
|
||||
setInputLocked(false);
|
||||
}
|
||||
|
||||
let interactiveWidgetHtml = "";
|
||||
if (actionData && actionData.type === "TASK_INTERACTIVE_CARD" && Array.isArray(actionData.tasks)) {
|
||||
interactiveWidgetHtml = renderInteractiveTaskCard(actionData.tasks);
|
||||
}
|
||||
|
||||
const botMsgHtml = `
|
||||
<div class="bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm max-w-2xl mb-3">
|
||||
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
|
||||
<i class="fa-solid fa-robot mr-1"></i> ${assistantTitle}
|
||||
</p>
|
||||
<p class="text-slate-800 text-xs sm:text-sm whitespace-pre-wrap leading-relaxed">${escapeHtml(replyText)}</p>
|
||||
${interactiveWidgetHtml}
|
||||
${actionButtonsHtml}
|
||||
</div>
|
||||
`;
|
||||
chatWindow.insertAdjacentHTML("beforeend", botMsgHtml);
|
||||
chatWindow.scrollTop = chatWindow.scrollHeight;
|
||||
|
||||
clearAttachedFile();
|
||||
|
||||
if (!isGuest && typeof loadTasks === 'function') {
|
||||
loadTasks();
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error("[Chat Error]", err);
|
||||
const errorHtml = `
|
||||
<div class="bg-red-50 border border-red-200 rounded-2xl p-3.5 max-w-2xl text-red-700 text-xs sm:text-sm mb-3">
|
||||
Ошибка связи с сервером.
|
||||
</div>
|
||||
`;
|
||||
chatWindow.insertAdjacentHTML("beforeend", errorHtml);
|
||||
chatWindow.scrollTop = chatWindow.scrollHeight;
|
||||
setInputLocked(false);
|
||||
} finally {
|
||||
if (sendBtn && !document.getElementById("user-input")?.disabled) {
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.classList.remove("opacity-50");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return "";
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const input = document.getElementById("user-input");
|
||||
const dropZone = document.getElementById("chat-window")?.parentElement;
|
||||
const dropOverlay = document.getElementById("drop-overlay");
|
||||
|
||||
if (input) {
|
||||
let historyIndex = -1;
|
||||
let localHistory = JSON.parse(localStorage.getItem("scud_chat_input_history") || "[]");
|
||||
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (input.disabled) return;
|
||||
const text = input.value.trim();
|
||||
if (text) {
|
||||
if (localHistory.length === 0 || localHistory[0] !== text) {
|
||||
localHistory.unshift(text);
|
||||
if (localHistory.length > 50) localHistory.pop();
|
||||
localStorage.setItem("scud_chat_input_history", JSON.stringify(localHistory));
|
||||
}
|
||||
historyIndex = -1;
|
||||
}
|
||||
sendMessage(e);
|
||||
updateInputHeight(input);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "ArrowUp") {
|
||||
const textBeforeCursor = input.value.substring(0, input.selectionStart);
|
||||
const isFirstLine = !textBeforeCursor.includes("\n");
|
||||
|
||||
if (isFirstLine && input.selectionStart === 0 && localHistory.length > 0) {
|
||||
if (historyIndex < localHistory.length - 1) {
|
||||
e.preventDefault();
|
||||
if (historyIndex === -1) {
|
||||
input.dataset.draft = input.value;
|
||||
}
|
||||
historyIndex++;
|
||||
input.value = localHistory[historyIndex];
|
||||
updateInputHeight(input);
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
const textAfterCursor = input.value.substring(input.selectionEnd);
|
||||
const isLastLine = !textAfterCursor.includes("\n");
|
||||
|
||||
if (isLastLine && input.selectionEnd === input.value.length && historyIndex >= 0) {
|
||||
e.preventDefault();
|
||||
if (historyIndex > 0) {
|
||||
historyIndex--;
|
||||
input.value = localHistory[historyIndex];
|
||||
} else {
|
||||
historyIndex = -1;
|
||||
input.value = input.dataset.draft || "";
|
||||
}
|
||||
updateInputHeight(input);
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (dropZone && dropOverlay) {
|
||||
["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, false);
|
||||
});
|
||||
|
||||
["dragenter", "dragover"].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, () => {
|
||||
dropOverlay.classList.remove("hidden");
|
||||
dropOverlay.classList.add("flex");
|
||||
}, false);
|
||||
});
|
||||
|
||||
["dragleave", "drop"].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, (e) => {
|
||||
if (eventName === "drop" || e.target === dropZone || !dropZone.contains(e.relatedTarget)) {
|
||||
dropOverlay.classList.add("hidden");
|
||||
dropOverlay.classList.remove("flex");
|
||||
}
|
||||
}, false);
|
||||
});
|
||||
|
||||
dropZone.addEventListener("drop", (e) => {
|
||||
const dt = e.dataTransfer;
|
||||
const files = dt.files;
|
||||
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
handleFileSelect({ target: { files: [file] } });
|
||||
|
||||
const fileInput = document.getElementById("file-input");
|
||||
if (fileInput) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
fileInput.files = dataTransfer.files;
|
||||
}
|
||||
}
|
||||
}, false);
|
||||
}
|
||||
});
|
||||
@@ -4,7 +4,7 @@ FILE: modules/web_api/static/js/chat/core.js
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: web_api / static / js / chat
|
||||
ROLE: Клиентский интерфейс диалога, Drag-and-Drop вложений, блокировка ввода
|
||||
при активных кнопках, вызов онлайн-редактора и отправка правок.
|
||||
при активных кнопках, динамический вызов инлайн-редактора и двусторонний Diff.
|
||||
===============================================================================
|
||||
*/
|
||||
|
||||
@@ -79,11 +79,6 @@ function disableAllActionButtons() {
|
||||
}
|
||||
|
||||
function handleActionButtonClick(text) {
|
||||
if (text === "action:open_editor") {
|
||||
openInlinePromptEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
disableAllActionButtons();
|
||||
setInputLocked(false);
|
||||
const input = document.getElementById("user-input");
|
||||
@@ -93,22 +88,123 @@ function handleActionButtonClick(text) {
|
||||
}
|
||||
}
|
||||
|
||||
function openInlinePromptEditor() {
|
||||
const editorContainer = document.getElementById("inline-prompt-editor-container");
|
||||
if (editorContainer) {
|
||||
function escapeHtml(text) {
|
||||
if (!text) return "";
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// --- [INLINE PROMPT EDITOR & FULL DIFF BUILDER] ---
|
||||
function openInlinePromptEditor(btnEl) {
|
||||
const cardContainer = btnEl.closest(".chat-message-card");
|
||||
if (!cardContainer) return;
|
||||
|
||||
const editorContainer = cardContainer.querySelector(".inline-prompt-editor-container");
|
||||
const diffView = cardContainer.querySelector(".prompt-preview-diff-view");
|
||||
const textarea = cardContainer.querySelector(".inline-prompt-textarea");
|
||||
|
||||
if (editorContainer && textarea) {
|
||||
editorContainer.classList.remove("hidden");
|
||||
const textarea = document.getElementById("inline-prompt-textarea");
|
||||
if (textarea) textarea.focus();
|
||||
|
||||
if (diffView) {
|
||||
const targetHeight = Math.max(diffView.offsetHeight, 320);
|
||||
textarea.style.height = `${targetHeight}px`;
|
||||
}
|
||||
|
||||
textarea.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function closeInlinePromptEditor() {
|
||||
const editorContainer = document.getElementById("inline-prompt-editor-container");
|
||||
function closeInlinePromptEditor(btnEl) {
|
||||
const cardContainer = btnEl.closest(".chat-message-card");
|
||||
if (!cardContainer) return;
|
||||
const editorContainer = cardContainer.querySelector(".inline-prompt-editor-container");
|
||||
if (editorContainer) editorContainer.classList.add("hidden");
|
||||
}
|
||||
|
||||
async function saveManualPromptDraft() {
|
||||
const textarea = document.getElementById("inline-prompt-textarea");
|
||||
function parsePromptIntoMap(text) {
|
||||
const map = new Map();
|
||||
if (!text) return map;
|
||||
const lines = text.split("\n");
|
||||
for (let line of lines) {
|
||||
let trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const match = trimmed.match(/^(\d+)[\.\s]+(\d+)[\.\s\:\-]*(.*)$/);
|
||||
if (match) {
|
||||
const key = `${match[1]}.${match[2]}`;
|
||||
map.set(key, { sec: parseInt(match[1]), itm: parseInt(match[2]), content: match[3].trim(), full: trimmed });
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function buildHighlightedPromptHtml(newRawText, originalBaselineText) {
|
||||
const origMap = parsePromptIntoMap(originalBaselineText);
|
||||
const newMap = parsePromptIntoMap(newRawText);
|
||||
|
||||
const formattedLines = [];
|
||||
const rawLines = newRawText.split("\n");
|
||||
const handledNewKeys = new Set();
|
||||
|
||||
let currentSection = null;
|
||||
|
||||
for (let line of rawLines) {
|
||||
let trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
formattedLines.push("");
|
||||
continue;
|
||||
}
|
||||
|
||||
const secMatch = trimmed.match(/^(\d+)[\.\s\:\-]+([^\d].*)$/);
|
||||
const subMatch = trimmed.match(/^(\d+)[\.\s]+(\d+)[\.\s\:\-]*(.*)$/);
|
||||
|
||||
if (subMatch) {
|
||||
const key = `${subMatch[1]}.${subMatch[2]}`;
|
||||
handledNewKeys.add(key);
|
||||
currentSection = parseInt(subMatch[1]);
|
||||
const cleanContent = subMatch[3].trim();
|
||||
const fullItemStr = `${subMatch[1]}.${subMatch[2]}. ${cleanContent}`;
|
||||
|
||||
if (!origMap.has(key) || origMap.get(key).content !== cleanContent) {
|
||||
// Добавленный или изменённый пункт
|
||||
formattedLines.push(` <span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300">${escapeHtml(fullItemStr)}</span>`);
|
||||
} else {
|
||||
formattedLines.push(` ${escapeHtml(fullItemStr)}`);
|
||||
}
|
||||
} else if (secMatch && !anyLower(secMatch[2].slice(0, 15))) {
|
||||
// Заголовок раздела
|
||||
currentSection = parseInt(secMatch[1]);
|
||||
formattedLines.push(escapeHtml(trimmed));
|
||||
} else {
|
||||
formattedLines.push(escapeHtml(trimmed));
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляем удаленные пункты (были в оригинале, но отсутствуют в новом тексте)
|
||||
for (let [origKey, origObj] of origMap.entries()) {
|
||||
if (!handledNewKeys.has(origKey)) {
|
||||
const strikeMarkup = ` <span class="line-through text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300 opacity-80">${origKey}. ${escapeHtml(origObj.content)} [УДАЛЕНИЕ]</span>`;
|
||||
formattedLines.push(strikeMarkup);
|
||||
}
|
||||
}
|
||||
|
||||
return formattedLines.join("\n");
|
||||
}
|
||||
|
||||
function anyLower(str) {
|
||||
return /[а-яa-z]/.test(str);
|
||||
}
|
||||
|
||||
async function saveManualPromptDraft(btnEl) {
|
||||
const cardContainer = btnEl.closest(".chat-message-card");
|
||||
if (!cardContainer) return;
|
||||
|
||||
const textarea = cardContainer.querySelector(".inline-prompt-textarea");
|
||||
const previewTextEl = cardContainer.querySelector(".prompt-preview-diff-view");
|
||||
if (!textarea) return;
|
||||
|
||||
const newText = textarea.value.trim();
|
||||
@@ -130,28 +226,18 @@ async function saveManualPromptDraft() {
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeInlinePromptEditor();
|
||||
const previewTextEl = document.getElementById("prompt-preview-diff-view");
|
||||
closeInlinePromptEditor(btnEl);
|
||||
if (previewTextEl) {
|
||||
previewTextEl.innerText = newText;
|
||||
const baseline = cardContainer.dataset.originalBaseline || "";
|
||||
const highlightedHtml = buildHighlightedPromptHtml(newText, baseline);
|
||||
previewTextEl.innerHTML = highlightedHtml;
|
||||
}
|
||||
alert("✓ Правки сохранены в черновике! Нажмите «Подтвердить» для применения в БД.");
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Ошибка сохранения черновика: " + err);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return "";
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// --- [CHAT SEND PIPELINE] ---
|
||||
async function sendMessage(e) {
|
||||
if (e && e.preventDefault) e.preventDefault();
|
||||
@@ -250,8 +336,10 @@ async function sendMessage(e) {
|
||||
iconMarkup = '<i class="fa-solid fa-pen-to-square text-xs"></i>';
|
||||
}
|
||||
|
||||
const clickHandler = isEdit ? "openInlinePromptEditor(this)" : `handleActionButtonClick('${escapeHtml(btn.value)}')`;
|
||||
|
||||
return `
|
||||
<button type="button" onclick="handleActionButtonClick('${escapeHtml(btn.value)}')"
|
||||
<button type="button" onclick="${clickHandler}"
|
||||
class="${btnClasses} font-semibold px-3 py-1.5 rounded-xl text-xs flex items-center gap-1.5 transition cursor-pointer">
|
||||
${iconMarkup}
|
||||
<span>${escapeHtml(btn.label)}</span>
|
||||
@@ -265,21 +353,25 @@ async function sendMessage(e) {
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Блокируем ввод при наличии кнопок
|
||||
setInputLocked(true);
|
||||
} else {
|
||||
setInputLocked(false);
|
||||
}
|
||||
|
||||
let previewEditorHtml = "";
|
||||
if (actionData && actionData.type === "PROMPT_PREVIEW" && actionData.raw_draft) {
|
||||
let rawDraftText = "";
|
||||
let baselineText = "";
|
||||
|
||||
if (actionData && actionData.type === "PROMPT_PREVIEW") {
|
||||
rawDraftText = actionData.raw_draft || "";
|
||||
baselineText = actionData.baseline_prompt || rawDraftText;
|
||||
previewEditorHtml = `
|
||||
<div id="inline-prompt-editor-container" class="hidden mt-3 p-3 bg-slate-50 border border-slate-300 rounded-xl space-y-2">
|
||||
<div class="inline-prompt-editor-container hidden mt-3 p-3 bg-slate-50 border border-slate-300 rounded-xl space-y-2">
|
||||
<p class="text-[11px] font-bold text-slate-700 uppercase"><i class="fa-solid fa-pen-to-square mr-1"></i> Ручное редактирование текста промпта:</p>
|
||||
<textarea id="inline-prompt-textarea" rows="10" class="w-full bg-white border border-slate-300 rounded-lg p-2.5 text-xs font-mono text-slate-800 focus:outline-none focus:border-indigo-600">${escapeHtml(actionData.raw_draft)}</textarea>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" onclick="closeInlinePromptEditor()" class="px-3 py-1.5 rounded-lg text-xs text-slate-600 bg-slate-200 hover:bg-slate-300">Свернуть</button>
|
||||
<button type="button" onclick="saveManualPromptDraft()" class="px-3 py-1.5 rounded-lg text-xs font-semibold text-white bg-indigo-600 hover:bg-indigo-700 shadow-sm">Сохранить правки</button>
|
||||
<textarea class="inline-prompt-textarea w-full bg-white border border-slate-300 rounded-lg p-3 text-xs font-mono text-slate-800 focus:outline-none focus:border-indigo-600 resize-y shadow-inner leading-relaxed">${escapeHtml(rawDraftText)}</textarea>
|
||||
<div class="flex justify-end gap-2 pt-1">
|
||||
<button type="button" onclick="closeInlinePromptEditor(this)" class="px-3 py-1.5 rounded-lg text-xs text-slate-600 bg-slate-200 hover:bg-slate-300 transition">Свернуть</button>
|
||||
<button type="button" onclick="saveManualPromptDraft(this)" class="px-3.5 py-1.5 rounded-lg text-xs font-semibold text-white bg-indigo-600 hover:bg-indigo-700 shadow-sm transition">Сохранить правки</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -291,11 +383,11 @@ async function sendMessage(e) {
|
||||
}
|
||||
|
||||
const botMsgHtml = `
|
||||
<div class="bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm max-w-2xl mb-3">
|
||||
<div class="chat-message-card bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm max-w-2xl mb-3" data-original-baseline="${escapeHtml(baselineText)}">
|
||||
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
|
||||
<i class="fa-solid fa-robot mr-1"></i> ${assistantTitle}
|
||||
</p>
|
||||
<div class="text-slate-800 text-xs sm:text-sm whitespace-pre-wrap leading-relaxed" id="prompt-preview-diff-view">${replyText}</div>
|
||||
<div class="prompt-preview-diff-view text-slate-800 text-xs sm:text-sm whitespace-pre-wrap leading-relaxed">${replyText}</div>
|
||||
${previewEditorHtml}
|
||||
${interactiveWidgetHtml}
|
||||
${actionButtonsHtml}
|
||||
|
||||
Reference in New Issue
Block a user