feat(llm): переход на реляционные узлы промпта, db_cli context и очистка от регулярок
This commit is contained in:
+78
-109
@@ -3,12 +3,13 @@
|
||||
FILE: modules/web_api/llm/agent.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: web_api / llm (Core Agent Coordinator)
|
||||
ROLE: Оркестратор диалога, диспетчер Function Calling и Topic Drift контроллер.
|
||||
ROLE: Оркестратор диалога, диспетчер реляционных узлов промпта и инструментов.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
# --- [SECTION 1: SYSTEM PATHS & IMPORTS] --- # ANCHOR[SYS_IMPORTS]
|
||||
import sys
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
@@ -16,7 +17,7 @@ from typing import List, Dict, Any, Tuple, Optional
|
||||
from .db.connection import get_db_connection
|
||||
from .db_tools import (
|
||||
db_get_active_system_prompt,
|
||||
db_add_system_prompt,
|
||||
db_apply_prompt_node_action,
|
||||
db_get_tool_action,
|
||||
db_get_tasks,
|
||||
db_update_task_status,
|
||||
@@ -26,7 +27,6 @@ from .db_tools import (
|
||||
db_set_session_state,
|
||||
db_get_session_state,
|
||||
db_clear_session_state,
|
||||
db_increment_session_idle,
|
||||
db_get_snapshots,
|
||||
db_delete_snapshots,
|
||||
db_get_current_server_time,
|
||||
@@ -35,16 +35,14 @@ from .db_tools import (
|
||||
db_purge_ephemeral_messages,
|
||||
db_get_stats,
|
||||
db_get_anomalies,
|
||||
db_get_session_states,
|
||||
db_get_reference
|
||||
)
|
||||
|
||||
from .schemas import TOOLS_SCHEMA
|
||||
from .core.calendar_utils import get_dynamic_calendar_context
|
||||
from .core.tool_injector import clean_raw_tool_tags, clean_output
|
||||
from .core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed
|
||||
from .core.ollama_client import call_ollama_chat
|
||||
from .core.fast_path import handle_fast_path_intercept
|
||||
from .core.prompt_merger import build_prompt_preview_merge
|
||||
|
||||
# --- [SECTION 2: LOGGING CONFIGURATION] --- # ANCHOR[LOGGING_CONFIG]
|
||||
logger = logging.getLogger("SCUD_AGENT")
|
||||
@@ -67,12 +65,7 @@ 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. Проверка Fast-Path команд (подтверждение, отмена, завершение).
|
||||
2. Формирование контекста и вызов Ollama LLM.
|
||||
3. Выполнение инструментов и обработка Context Guard.
|
||||
"""
|
||||
"""Главный конвейер диалога с поддержкой реляционного редактора промпта."""
|
||||
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
|
||||
@@ -94,28 +87,26 @@ def process_chat_message(
|
||||
user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость"
|
||||
|
||||
active_state_context = ""
|
||||
if session_state and session_state.get("state_type") in ["PROMPT_PREVIEW", "PROMPT_FOLLOWUP"]:
|
||||
active_state_context = (
|
||||
"\n[АКТИВНОЕ ДЕЙСТВИЕ В СЕССИИ]\n"
|
||||
"В данный момент оператор находится в процессе настройки системного промпта.\n"
|
||||
"- Если оператор просит продолжить правки — продолжай работу с ним.\n"
|
||||
"- Если оператор переключился на другую тему — выполни его команду штатно.\n"
|
||||
)
|
||||
if session_state:
|
||||
state_type = session_state.get("state_type", "GENERAL")
|
||||
active_state_context = f"\n[АКТИВНЫЙ РЕЖИМ СЕССИИ: {state_type}]\n"
|
||||
|
||||
system_prompt_content = (
|
||||
f"Ты — интеллектуальный ассистент SCUD Orion AI. "
|
||||
f"Ты управляешь задачами, аналитикой СКУД и системными настройками с помощью инструментов (tools).\n\n"
|
||||
f"[ОКРУЖЕНИЕ]\n"
|
||||
f"- Пользователь: {user_info}\n"
|
||||
f"- {calendar_context}\n"
|
||||
f"{active_state_context}\n"
|
||||
f"[ПРАВИЛА ИСПОЛЬЗОВАНИЯ ИНСТРУМЕНТОВ]\n"
|
||||
f"1. Для любых изменений системного промпта ВСЕГДА вызывай db_preview_prompt_merge(prompt_text=...).\n"
|
||||
f"2. Для просмотра системного промпта ВСЕГДА вызывай db_get_system_prompt().\n"
|
||||
f"3. Для просмотра задач ВСЕГДА вызывай db_get_tasks().\n"
|
||||
f"4. Не симулируй выполнение функций текстом — сразу вызывай инструмент.\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(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}"
|
||||
)
|
||||
|
||||
user_msg_object = {"role": "user", "content": full_user_content}
|
||||
|
||||
@@ -135,20 +126,24 @@ def process_chat_message(
|
||||
messages = [{"role": "system", "content": system_prompt_content}] + clean_db_history + [user_msg_object]
|
||||
msg = call_ollama_chat(messages, tools=TOOLS_SCHEMA, is_vision=False)
|
||||
|
||||
raw_text_reply = msg.get("content", "")
|
||||
tool_calls = msg.get("tool_calls", [])
|
||||
tool_calls = inject_tools_if_needed(user_message, raw_text_reply, tool_calls)
|
||||
|
||||
if tool_calls:
|
||||
logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})")
|
||||
messages.append(msg)
|
||||
|
||||
tool_names_called = []
|
||||
|
||||
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)
|
||||
|
||||
# ANCHOR[TASK_INTERACTIVE_DISPATCH]
|
||||
if fn_name == "db_get_tasks":
|
||||
raw_tasks = db_get_tasks(user_id)
|
||||
reply_text = "Вот интерактивный список ваших текущих задач:"
|
||||
@@ -158,13 +153,48 @@ def process_chat_message(
|
||||
"tasks": raw_tasks
|
||||
}
|
||||
|
||||
elif fn_name == "db_preview_prompt_merge":
|
||||
proposed_text = fn_args.get("prompt_text") or fn_args.get("proposed_prompt") or ""
|
||||
if isinstance(fn_args, str):
|
||||
proposed_text = fn_args
|
||||
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()
|
||||
|
||||
merged_prompt = build_prompt_preview_merge(db_get_active_system_prompt(), user_message, proposed_text)
|
||||
db_set_session_state(session_id, "PROMPT_PREVIEW", {"draft_text": merged_prompt, "idle_turns": 0})
|
||||
with get_db_connection() as temp_conn:
|
||||
temp_cursor = temp_conn.cursor()
|
||||
temp_cursor.execute("SELECT section_id, item_id, content FROM system_prompt_nodes WHERE prompt_name = 'main_agent' AND is_active = 1 ORDER BY section_id, item_id")
|
||||
existing_nodes = temp_cursor.fetchall()
|
||||
|
||||
nodes_dict = {(sec, itm): txt for sec, itm, txt in existing_nodes}
|
||||
|
||||
if action == "DELETE":
|
||||
nodes_dict.pop((sec_id, itm_id), None)
|
||||
else:
|
||||
nodes_dict[(sec_id, itm_id)] = content
|
||||
|
||||
virtual_lines = []
|
||||
curr_sec = None
|
||||
for (s_id, i_id), txt in sorted(nodes_dict.items()):
|
||||
if i_id == 0:
|
||||
if curr_sec is not None:
|
||||
virtual_lines.append("")
|
||||
virtual_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)
|
||||
|
||||
merged_prompt = "\n".join([re.sub(r'<[^>]+>', '', l) for l in virtual_lines])
|
||||
diff_html = "\n".join(virtual_lines)
|
||||
|
||||
db_set_session_state(session_id, "PROMPT_PREVIEW", {
|
||||
"draft_text": merged_prompt,
|
||||
"action": action,
|
||||
"section_id": sec_id,
|
||||
"item_id": itm_id,
|
||||
"content": content
|
||||
})
|
||||
|
||||
with get_db_connection() as conn_fix:
|
||||
conn_fix.cursor().execute("""
|
||||
@@ -175,16 +205,19 @@ def process_chat_message(
|
||||
conn_fix.commit()
|
||||
|
||||
preview_reply = (
|
||||
f"Ваше изменение успешно предпросмотрено. Полный обновленный системный промпт теперь выглядит так:\n\n"
|
||||
f"{merged_prompt}\n\n"
|
||||
f"Для применения изменений подтвердите действие («подтверждаю») или отмените («отмена»)."
|
||||
f"Предпросмотр изменений системного промпта:\n\n"
|
||||
f"{diff_html}\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), {
|
||||
return preview_reply, db_get_chat_history(session_id), {
|
||||
"type": "PROMPT_PREVIEW",
|
||||
"raw_draft": merged_prompt,
|
||||
"buttons": [
|
||||
{"label": "Подтвердить", "value": "подтверждаю", "style": "primary"},
|
||||
{"label": "Отменить", "value": "отмена", "style": "danger"}
|
||||
{"label": "Отменить", "value": "отмена", "style": "danger"},
|
||||
{"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -223,90 +256,26 @@ def process_chat_message(
|
||||
|
||||
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()
|
||||
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), {
|
||||
"type": action_cfg.get("action_type"),
|
||||
"buttons": action_cfg.get("buttons", [])
|
||||
}
|
||||
tool_result_content = json.dumps(res, ensure_ascii=False)
|
||||
|
||||
elif fn_name == "db_delete_snapshots":
|
||||
res = db_delete_snapshots(snapshot_id=fn_args.get("snapshot_id"), day_str=fn_args.get("day_str"))
|
||||
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()
|
||||
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), {
|
||||
"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})
|
||||
|
||||
# ANCHOR[SECONDARY_PASS]
|
||||
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))
|
||||
|
||||
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
|
||||
|
||||
# Topic Drift & Context Guard
|
||||
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)
|
||||
is_output_ephemeral = 1 if any(name in ["db_get_system_prompt", "db_get_system_prompts", "db_prompt_node_edit"] for name in tool_names_called) else 0
|
||||
|
||||
db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_output_ephemeral)
|
||||
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
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: modules/web_api/llm/core/fast_path.py
|
||||
ROLE: Детерминированный быстрый перехват команд оператора без вызова LLM:
|
||||
подтверждение, отмена черновиков и завершение диалога с очисткой памяти.
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: web_api / llm / core
|
||||
ROLE: Детерминированный перехват подтверждения, отмены и мгновенной очистки
|
||||
эфемерного контекста без повторного вывода кнопок-вилок.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
@@ -12,8 +14,6 @@ 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,
|
||||
@@ -31,65 +31,39 @@ def handle_fast_path_intercept(
|
||||
session_state: Optional[Dict[str, Any]]
|
||||
) -> Optional[Tuple[str, list, Optional[Dict[str, Any]]]]:
|
||||
"""
|
||||
Проверяет входящее сообщение на детерминированные команды.
|
||||
Если команда перехвачена — возвращает кортеж (reply_text, chat_history, metadata).
|
||||
Если перехват не требуется — возвращает None.
|
||||
1. Подтвердить -> запись в БД + db_purge_ephemeral_messages + инфо-текст.
|
||||
2. Отменить -> сброс стейта + db_purge_ephemeral_messages + инфо-текст.
|
||||
"""
|
||||
if not session_state or session_state.get("state_type") != "PROMPT_PREVIEW":
|
||||
return None
|
||||
|
||||
user_msg_clean = user_message.lower().strip(" .!?:;")
|
||||
|
||||
# 1. Перехват завершения работы с очисткой эфемерных сообщений
|
||||
if user_msg_clean in ["нет, спасибо", "нет, закончить настройку", "закончить настройку", "завершить", "нет"]:
|
||||
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. ПОДТВЕРДИТЬ ИЗМЕНЕНИЯ
|
||||
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}")
|
||||
|
||||
reply_text = "Хорошо. Настройка завершена, контекст диалога чист. Чем я могу помочь дальше?"
|
||||
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", "")
|
||||
# 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}")
|
||||
|
||||
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 []
|
||||
}
|
||||
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,15 +1,15 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: modules/web_api/llm/core/prompt_merger.py
|
||||
ROLE: Алгоритмы парсинга, предпросмотра и детерминированного слияния правок
|
||||
системного промпта (добавление, удаление и замена пунктов).
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: web_api / llm / core
|
||||
ROLE: Алгоритмы парсинга, предпросмотра, слияния и визуального выделения правок.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
# ANCHOR[PROMPT_MERGER_IMPORTS]
|
||||
import re
|
||||
import logging
|
||||
from typing import Tuple
|
||||
|
||||
logger = logging.getLogger("PROMPT_MERGER")
|
||||
|
||||
@@ -17,9 +17,7 @@ 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()
|
||||
|
||||
@@ -56,4 +54,21 @@ def build_prompt_preview_merge(current_prompt: str, user_message: str, proposed_
|
||||
new_lines.append(f" {clean_item}")
|
||||
return "\n".join(new_lines)
|
||||
|
||||
return proposed_text or current_prompt
|
||||
return proposed_text or current_prompt
|
||||
|
||||
|
||||
def highlight_prompt_diff(old_prompt: str, new_prompt: str) -> str:
|
||||
"""
|
||||
Генерирует текст с подсветкой добавленных/измененных строк HTML-классами Tailwind.
|
||||
"""
|
||||
old_lines_set = {line.strip() for line in old_prompt.splitlines() if line.strip()}
|
||||
diff_lines = []
|
||||
|
||||
for line in new_prompt.splitlines():
|
||||
if line.strip() and line.strip() not in old_lines_set:
|
||||
# Выделяем новую или измененную строку красным цветом
|
||||
diff_lines.append(f'<span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-200">{line}</span>')
|
||||
else:
|
||||
diff_lines.append(line)
|
||||
|
||||
return "\n".join(diff_lines)
|
||||
@@ -1,3 +1,6 @@
|
||||
"""
|
||||
FILE: modules/web_api/llm/core/tool_injector.py
|
||||
"""
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
@@ -5,6 +8,7 @@ from typing import List, Dict, Any
|
||||
|
||||
logger = logging.getLogger("TOOL_INJECTOR")
|
||||
|
||||
|
||||
def clean_raw_tool_tags(text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
@@ -14,15 +18,25 @@ def clean_raw_tool_tags(text: str) -> str:
|
||||
text = re.sub(r'</tool_call>', '', text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def clean_output(text: str) -> str:
|
||||
if not text:
|
||||
return text
|
||||
artifacts = ["почемучто", "почто", "почему что"]
|
||||
lower_text = text.lower()
|
||||
|
||||
# Фильтрация паразитных артефактов токенизатора Qwen
|
||||
artifacts = [
|
||||
"почемучка", "почемучка,", "почемучка!", "почемучка?",
|
||||
"почемучто", "почто", "почему что", "почему-то",
|
||||
"здравствуйте!", "привет!"
|
||||
]
|
||||
|
||||
cleaned = text.strip()
|
||||
for art in artifacts:
|
||||
if lower_text.startswith(art):
|
||||
text = text[len(art):].lstrip(",.!?:; -")
|
||||
return text.strip()
|
||||
if cleaned.lower().startswith(art):
|
||||
cleaned = cleaned[len(art):].lstrip(",.!?:; -")
|
||||
|
||||
return cleaned.strip()
|
||||
|
||||
|
||||
def inject_tools_if_needed(user_message: str, raw_text_content: str, tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
if tool_calls:
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: modules/web_api/llm/db/db_prompts.py
|
||||
PROJECT: SCUD Orion AI
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: web_api / llm / db
|
||||
ROLE: Управление системными промптами, правилами базы знаний,
|
||||
декларативным реестром действий инструментов (tool_action_registry)
|
||||
и расширенным сессионным состоянием (session_states + idle_turns).
|
||||
ROLE: Реляционное управление системным промптом (таблица system_prompt_nodes),
|
||||
базой знаний, реестром действий и сессионными стейтами.
|
||||
===============================================================================
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
@@ -14,18 +15,115 @@ from .connection import get_db_connection
|
||||
logger = logging.getLogger("DB_PROMPTS")
|
||||
|
||||
|
||||
def db_get_active_system_prompt() -> str:
|
||||
"""Извлекает актуальный активный системный промпт из SQLite."""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT prompt_text FROM system_prompts WHERE is_active = 1 ORDER BY id DESC LIMIT 1")
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
return row["prompt_text"] if row else "Ты — ИИ-ассистент SCUD Orion AI."
|
||||
def init_prompt_nodes_table():
|
||||
"""Создает реляционную таблицу узлов промпта и заполняет базовыми данными."""
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS system_prompt_nodes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
prompt_name TEXT DEFAULT 'main_agent',
|
||||
section_id INTEGER NOT NULL,
|
||||
item_id INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(prompt_name, section_id, item_id)
|
||||
);
|
||||
""")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_prompt_nodes ON system_prompt_nodes(prompt_name, section_id, item_id);")
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM system_prompt_nodes WHERE prompt_name = 'main_agent'")
|
||||
if cursor.fetchone()[0] == 0:
|
||||
seed_nodes = [
|
||||
# Раздел 1
|
||||
(1, 0, "РОЛЬ И ЗАДАЧИ АССИСТЕНТА"),
|
||||
(1, 1, "Управление бэклогом задач проекта (через db_get_tasks, db_add_task, db_update_task_status, db_delete_task)."),
|
||||
(1, 2, "Консультация по правилам и арбитражу кадровых данных/СКУД из базы знаний (через db_get_rules)."),
|
||||
(1, 3, "Предоставление справки о возможностях и примерах команд (СТРОГО через db_get_reference)."),
|
||||
(1, 4, "Просмотр аномалий СКУД ⟷ 1С (СТРОГО через db_get_anomalies)."),
|
||||
(1, 5, "Поддержка диалога с операторами и администраторами системы."),
|
||||
(1, 7, "Работа с логами, снапшотами и срезами СКУД (через db_get_snapshots)."),
|
||||
|
||||
# Раздел 2
|
||||
(2, 0, "ПРАВИЛА ВЫЗОВА ИНСТРУМЕНТОВ И ДАТ"),
|
||||
(2, 1, "ОБЯЗАТЕЛЬНЫЙ ПРЕВЬЮ-МЕРДЖ: Категорически ЗАПРЕЩЕНО изменять промпт напрямую! При ЛЮБОМ запросе пользователя на изменение системного промпта Ты ОБЯЗАН вызвать инструмент db_prompt_node_edit."),
|
||||
(2, 2, "БЕЗУСЛОВНОЕ ПОДТВЕРЖДЕНИЕ ПРЕВЬЮ: Инструмент db_confirm_prompt_preview вызывается СТРОГО после того, как пользователь напишет 'подтверждаю', 'да', 'сохраняй'."),
|
||||
(2, 3, "ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ ЗАДАЧ: При запросе на удаление задач КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО сразу вызывать db_delete_task! Ты ОБЯЗАН сначала спросить пользователя подтверждение."),
|
||||
(2, 4, "ИСПОЛЬЗОВАНИЕ КАЛЕНДАРЯ: При любых вопросах про даты БЕРИ ТОЧНУЮ ДАТУ ИЗ [СИСТЕМНОГО КАЛЕНДАРЯ СЕРВЕРА] В НАЧАЛЕ КОНТЕКСТА."),
|
||||
(2, 5, "СТРОГИЙ ВЫЗОВ АНОМАЛИЙ: При запросах аномалий или нарушений Ты ОБЯЗАН вызвать инструмент db_get_anomalies."),
|
||||
(2, 6, "СТРОГИЙ ВЫЗОВ СПРАВОЧНИКА: При запросах о возможностях, примерах запросов или командах Ты ОБЯЗАН вызывать db_get_reference."),
|
||||
(2, 7, "Запрещено писать названия функций или код вызова текстом на экран."),
|
||||
(2, 8, "СТРОГИЙ ВЫЗОВ СНАПШОТОВ: При ЛЮБЫХ запросах про снапшоты, срезы или логи СКУД за дату/день недели Ты ОБЯЗАН вызывать инструмент db_get_snapshots."),
|
||||
|
||||
# Раздел 3
|
||||
(3, 0, "ПРАВИЛА СТИЛЯ"),
|
||||
(3, 1, "Никогда не начинай ответ со склеек или слов-паразитов."),
|
||||
(3, 2, "Отвечай в чистом текстовом формате (plain text) без спецсимволов или ###, если прямо не попросили."),
|
||||
(3, 3, "Сохраняй инженерный, лаконичный и профессиональный стиль.")
|
||||
]
|
||||
cursor.executemany("""
|
||||
INSERT OR IGNORE INTO system_prompt_nodes (prompt_name, section_id, item_id, content)
|
||||
VALUES ('main_agent', ?, ?, ?)
|
||||
""", seed_nodes)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def db_get_active_system_prompt(prompt_name: str = "main_agent") -> str:
|
||||
"""Собирает структурированный текст промпта из реляционной таблицы узлов."""
|
||||
init_prompt_nodes_table()
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT section_id, item_id, content
|
||||
FROM system_prompt_nodes
|
||||
WHERE prompt_name = ? AND is_active = 1
|
||||
ORDER BY section_id ASC, item_id ASC
|
||||
""", (prompt_name,))
|
||||
rows = cursor.fetchall()
|
||||
|
||||
if not rows:
|
||||
return "Ты — ИИ-ассистент SCUD Orion AI."
|
||||
|
||||
lines = []
|
||||
current_section = None
|
||||
|
||||
for sec_id, itm_id, content in rows:
|
||||
if itm_id == 0:
|
||||
if current_section is not None:
|
||||
lines.append("")
|
||||
lines.append(f"{sec_id}. {content}")
|
||||
current_section = sec_id
|
||||
else:
|
||||
lines.append(f" {sec_id}.{itm_id}. {content}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def db_apply_prompt_node_action(action: str, section_id: int, item_id: int, content: str = "", prompt_name: str = "main_agent"):
|
||||
"""Прямое добавление, изменение или удаление узла в БД."""
|
||||
init_prompt_nodes_table()
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
action_clean = action.upper()
|
||||
if action_clean in ["ADD", "UPDATE"]:
|
||||
cursor.execute("""
|
||||
INSERT INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active, updated_at)
|
||||
VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(prompt_name, section_id, item_id) DO UPDATE SET
|
||||
content = excluded.content,
|
||||
is_active = 1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""", (prompt_name, section_id, item_id, content))
|
||||
elif action_clean == "DELETE":
|
||||
cursor.execute("""
|
||||
DELETE FROM system_prompt_nodes
|
||||
WHERE prompt_name = ? AND section_id = ? AND item_id = ?
|
||||
""", (prompt_name, section_id, item_id))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def db_get_tool_action(tool_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Получение шаблона, кнопок и настроек эфемерности инструмента из SQLite."""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
@@ -44,33 +142,7 @@ def db_get_tool_action(tool_name: str) -> Optional[Dict[str, Any]]:
|
||||
return None
|
||||
|
||||
|
||||
def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]:
|
||||
"""Сохраняет новую версию системного промпта и активирует её."""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("BEGIN IMMEDIATE;")
|
||||
cursor.execute("SELECT id FROM system_prompts WHERE name = ?", (name,))
|
||||
existing = cursor.fetchone()
|
||||
if existing:
|
||||
cursor.execute(
|
||||
"UPDATE system_prompts SET prompt_text = ?, is_active = 1, updated_at = CURRENT_TIMESTAMP WHERE name = ?",
|
||||
(prompt_text, name)
|
||||
)
|
||||
else:
|
||||
cursor.execute(
|
||||
"INSERT INTO system_prompts (name, prompt_text, is_active) VALUES (?, ?, 1)",
|
||||
(name, prompt_text)
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "success", "message": "Системный промпт успешно обновлен"}
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при сохранении промпта в БД: {e}")
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
def db_get_rules() -> List[Dict[str, Any]]:
|
||||
"""Извлекает список глобальных правил компании из ai_knowledge_base."""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT id, rule_text FROM ai_knowledge_base ORDER BY id ASC")
|
||||
@@ -79,23 +151,11 @@ def db_get_rules() -> List[Dict[str, Any]]:
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# РАБОТА С СЕССИОННЫМИ СОСТОЯНИЯМИ, ЧЕРНОВИКАМИ И СЧЕТЧИКОМ ОТВЛЕЧЕНИЙ (IDLE_TURNS)
|
||||
# =============================================================================
|
||||
|
||||
def db_set_session_state(session_id: str, state_type: str, data: Any) -> None:
|
||||
"""
|
||||
Сохраняет состояние сессии в SQLite.
|
||||
Если передан dict/list — автоматически сериализует его в JSON-строку.
|
||||
"""
|
||||
"""Сохраняет состояние сессии в SQLite."""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
if isinstance(data, (dict, list)):
|
||||
payload_str = json.dumps(data, ensure_ascii=False)
|
||||
else:
|
||||
payload_str = str(data) if data is not None else ""
|
||||
|
||||
payload_str = json.dumps(data, ensure_ascii=False) if isinstance(data, (dict, list)) else (str(data) if data is not None else "")
|
||||
cursor.execute("""
|
||||
INSERT INTO session_states (session_id, state_type, pending_data, updated_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
@@ -109,63 +169,23 @@ def db_set_session_state(session_id: str, state_type: str, data: Any) -> None:
|
||||
|
||||
|
||||
def db_get_session_state(session_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Извлекает состояние сессии из SQLite.
|
||||
Если в pending_data лежит валидный JSON-объект — парсит его в data_json.
|
||||
"""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT session_id, state_type, pending_data, updated_at FROM session_states WHERE session_id = ?",
|
||||
(session_id,)
|
||||
)
|
||||
cursor.execute("SELECT session_id, state_type, pending_data, updated_at FROM session_states WHERE session_id = ?", (session_id,))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
res = dict(row)
|
||||
raw_data = res.get("pending_data") or ""
|
||||
|
||||
try:
|
||||
if raw_data.strip().startswith("{") or raw_data.strip().startswith("["):
|
||||
res["data_json"] = json.loads(raw_data)
|
||||
else:
|
||||
res["data_json"] = None
|
||||
res["data_json"] = json.loads(raw_data) if raw_data.strip().startswith(("{", "[")) else None
|
||||
except Exception:
|
||||
res["data_json"] = None
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def db_increment_session_idle(session_id: str) -> int:
|
||||
"""
|
||||
Инкрементирует счетчик idle_turns для активного черновика сессии.
|
||||
Возвращает обновленное значение счетчика отвлечений.
|
||||
"""
|
||||
state = db_get_session_state(session_id)
|
||||
if not state:
|
||||
return 0
|
||||
|
||||
data_meta = state.get("data_json")
|
||||
if isinstance(data_meta, dict):
|
||||
current_turns = data_meta.get("idle_turns", 0) + 1
|
||||
data_meta["idle_turns"] = current_turns
|
||||
db_set_session_state(session_id, state["state_type"], data_meta)
|
||||
return current_turns
|
||||
else:
|
||||
# Если ранее данные были сохранены обычной строкой
|
||||
new_meta = {
|
||||
"draft_text": state.get("pending_data", ""),
|
||||
"idle_turns": 1
|
||||
}
|
||||
db_set_session_state(session_id, state["state_type"], new_meta)
|
||||
return 1
|
||||
|
||||
|
||||
def db_clear_session_state(session_id: str) -> None:
|
||||
"""Сбрасывает и удаляет активное состояние сессии."""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,))
|
||||
@@ -173,28 +193,10 @@ def db_clear_session_state(session_id: str) -> None:
|
||||
conn.close()
|
||||
|
||||
|
||||
def db_get_session_states() -> Dict[str, Any]:
|
||||
"""Возвращает реестр всех активных сессий."""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT session_id, state_type, updated_at FROM session_states")
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
return {"status": "success", "active_sessions": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# СЛУЖЕБНАЯ ДИАГНОСТИКА И СТАТИСТИКА
|
||||
# =============================================================================
|
||||
|
||||
def db_get_stats() -> Dict[str, Any]:
|
||||
"""Возвращает статистику по количеству записей в таблицах."""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
tables = [
|
||||
'scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history',
|
||||
'ai_knowledge_base', 'system_prompts', 'session_states', 'tasks'
|
||||
]
|
||||
tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'system_prompt_nodes', 'session_states', 'tasks']
|
||||
stats = {}
|
||||
for t in tables:
|
||||
try:
|
||||
@@ -207,7 +209,6 @@ def db_get_stats() -> Dict[str, Any]:
|
||||
|
||||
|
||||
def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Извлекает журнал зафиксированных аномалий."""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
query = "SELECT anomaly_date, fio, anomaly_type, details FROM anomalies_history"
|
||||
@@ -224,7 +225,6 @@ def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[s
|
||||
|
||||
|
||||
def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Извлекает справочные команды и примеры подсказок."""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
query = "SELECT category, title, example_prompt, description FROM system_reference"
|
||||
@@ -236,4 +236,51 @@ def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]:
|
||||
cursor.execute(query, params)
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
return {"status": "success", "count": len(rows), "reference_items": [dict(r) for r in rows]}
|
||||
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:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM system_prompt_nodes WHERE prompt_name = ?", (name,))
|
||||
|
||||
current_sec = 1
|
||||
current_itm = 0
|
||||
|
||||
for line in prompt_text.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
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
|
||||
else:
|
||||
current_itm += 1
|
||||
content = stripped
|
||||
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active)
|
||||
VALUES (?, ?, ?, ?, 1)
|
||||
""", (name, current_sec, current_itm, content))
|
||||
|
||||
conn.commit()
|
||||
return {"status": "success", "message": "Системный промпт успешно сохранен по узлам"}
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при разборе промпта в узлы: {e}")
|
||||
return {"status": "error", "error": str(e)}
|
||||
@@ -9,13 +9,12 @@ from .db.db_snapshots import db_get_snapshots, db_delete_snapshots
|
||||
from .db.db_prompts import (
|
||||
db_get_active_system_prompt,
|
||||
db_add_system_prompt,
|
||||
db_apply_prompt_node_action,
|
||||
db_get_tool_action,
|
||||
db_get_rules,
|
||||
db_set_session_state,
|
||||
db_get_session_state,
|
||||
db_clear_session_state,
|
||||
db_increment_session_idle,
|
||||
db_get_session_states,
|
||||
db_get_stats,
|
||||
db_get_anomalies,
|
||||
db_get_reference
|
||||
|
||||
@@ -82,18 +82,18 @@ TOOLS_SCHEMA = [
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_delete_snapshots",
|
||||
"description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"},
|
||||
"day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"}
|
||||
}
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_delete_snapshots",
|
||||
"description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"},
|
||||
"day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
@@ -173,49 +173,35 @@ TOOLS_SCHEMA = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_add_system_prompt",
|
||||
"description": "Прямое сохранение системного промпта в БД без предварительного просмотра.",
|
||||
"name": "db_prompt_node_edit",
|
||||
"description": (
|
||||
"Сформировать предпросмотр изменения системного промпта через реляционные узлы. "
|
||||
"Вызывай при любых запросах на добавление, изменение или удаление пунктов. "
|
||||
"Раздел и пункт передавай числами (например, для пункта '2.9' -> section_id=2, item_id=9)."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Имя промпта, по умолчанию main_agent"},
|
||||
"prompt_text": {"type": "string", "description": "Полный текст системного промпта"}
|
||||
},
|
||||
"required": ["prompt_text"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_preview_prompt_merge",
|
||||
"description": "Сформировать предпросмотр изменения системного промпта. Вызывается при любых запросах на добавление ('добавь пункт...'), изменение или удаление пунктов системного промпта ('удали пункт...').",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt_text": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"description": "Полный текст добавляемого пункта или номер/описание удаляемого пункта."
|
||||
"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": ["prompt_text"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_confirm_prompt_preview",
|
||||
"description": "Подтвердить и сохранить текущее подготовленное превью в БД. Вызывай этот инструмент, когда пользователь говорит 'подтверждаю', 'да', 'вноси', 'применяй', 'сохраняй' или одобряет превью в любой форме.",
|
||||
"parameters": {"type": "object", "properties": {}}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_cancel_prompt_preview",
|
||||
"description": "Отменить текущее превью системного промпта и сбросить изменения. Вызывай, когда пользователь явно отказывается от изменений.",
|
||||
"parameters": {"type": "object", "properties": {}}
|
||||
"required": ["action", "section_id", "item_id"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
+11
-7
@@ -73,16 +73,20 @@ async def favicon():
|
||||
def serve_static_fallback(file_path: str):
|
||||
clean_path = file_path.lstrip("/")
|
||||
target = os.path.join(STATIC_DIR, clean_path)
|
||||
|
||||
if os.path.isfile(target):
|
||||
if clean_path.endswith(".js"):
|
||||
return FileResponse(target, media_type="application/javascript")
|
||||
elif clean_path.endswith(".css"):
|
||||
return FileResponse(target, media_type="text/css")
|
||||
return FileResponse(target)
|
||||
|
||||
# Резервный рекурсивный поиск файла в static
|
||||
filename = os.path.basename(clean_path)
|
||||
target_js = os.path.join(STATIC_DIR, "js", filename)
|
||||
if filename.endswith(".js") and os.path.isfile(target_js):
|
||||
return FileResponse(target_js, media_type="application/javascript")
|
||||
|
||||
target_css = os.path.join(STATIC_DIR, "css", filename)
|
||||
if filename.endswith(".css") and os.path.isfile(target_css):
|
||||
return FileResponse(target_css, media_type="text/css")
|
||||
for root, _, files in os.walk(STATIC_DIR):
|
||||
if filename in files:
|
||||
full_path = os.path.join(root, filename)
|
||||
media = "application/javascript" if filename.endswith(".js") else "text/css"
|
||||
return FileResponse(full_path, media_type=media)
|
||||
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
@@ -1,20 +1,31 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: modules/web_api/routers/chat.py
|
||||
ROLE: Маршрутизация диалогов с LLM (авторизованный и гостевой чаты).
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: web_api / routers
|
||||
ROLE: Маршрутизация диалогов с LLM и эндпоинт сохранения онлайн-черновиков.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
# ANCHOR[CHAT_ROUTER_IMPORTS]
|
||||
from typing import Optional, Dict, Any
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, Form
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .auth import get_current_user
|
||||
from llm.agent import process_chat_message
|
||||
from llm.file_parser import extract_text_from_file
|
||||
from llm.db_tools import db_set_session_state, db_get_session_state
|
||||
|
||||
router = APIRouter(prefix="/api/v1/chat", tags=["chat"])
|
||||
|
||||
|
||||
# ANCHOR[DRAFT_SCHEMA]
|
||||
class UpdateDraftRequest(BaseModel):
|
||||
session_id: str
|
||||
draft_text: str
|
||||
|
||||
|
||||
# ANCHOR[CHAT_ENDPOINTS]
|
||||
@router.post("")
|
||||
async def chat_endpoint(
|
||||
@@ -38,6 +49,7 @@ async def chat_endpoint(
|
||||
)
|
||||
return {"reply": reply, "history": history, "action_type": action_type}
|
||||
|
||||
|
||||
@router.post("/guest")
|
||||
async def guest_chat_endpoint(
|
||||
session_id: str = Form("web_session_main"),
|
||||
@@ -57,4 +69,18 @@ async def guest_chat_endpoint(
|
||||
image_b64=parsed_file["image_b64"],
|
||||
session_id=session_id
|
||||
)
|
||||
return {"reply": reply, "history": history, "action_type": action_type}
|
||||
return {"reply": reply, "history": history, "action_type": action_type}
|
||||
|
||||
|
||||
@router.post("/draft")
|
||||
def update_draft_endpoint(
|
||||
req: UpdateDraftRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""Обновляет черновик системного промпта напрямую из интерактивной онлайн-формы."""
|
||||
state = db_get_session_state(req.session_id)
|
||||
if not state or state.get("state_type") != "PROMPT_PREVIEW":
|
||||
raise HTTPException(status_code=400, detail="Нет активного превью для редактирования")
|
||||
|
||||
db_set_session_state(req.session_id, "PROMPT_PREVIEW", {"draft_text": req.draft_text.strip()})
|
||||
return {"status": "success", "message": "Черновик успешно обновлен в сессии"}
|
||||
@@ -254,9 +254,11 @@
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Подключение скриптов интерфейса -->
|
||||
<script src="/static/js/auth.js"></script>
|
||||
<script src="/static/js/tasks.js"></script>
|
||||
<script src="/static/js/chat.js"></script>
|
||||
<script src="/static/js/chat/task_widget.js"></script>
|
||||
<script src="/static/js/chat/core.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,18 +2,10 @@
|
||||
===============================================================================
|
||||
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) {
|
||||
if (!el) return;
|
||||
el.style.height = "24px";
|
||||
@@ -51,7 +43,29 @@ function clearAttachedFile() {
|
||||
if (previewContainer) previewContainer.classList.add("hidden");
|
||||
}
|
||||
|
||||
// --- [SECTION 2: ACTION BUTTONS CORE] --- # ANCHOR[INTERACTIVE_BUTTONS_CORE]
|
||||
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 => {
|
||||
@@ -64,6 +78,7 @@ function disableAllActionButtons() {
|
||||
|
||||
function handleActionButtonClick(text) {
|
||||
disableAllActionButtons();
|
||||
setInputLocked(false);
|
||||
const input = document.getElementById("user-input");
|
||||
if (input) {
|
||||
input.value = text;
|
||||
@@ -71,7 +86,7 @@ function handleActionButtonClick(text) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- [SECTION 3: INTERACTIVE TASK WIDGET] --- # ANCHOR[INTERACTIVE_TASK_WIDGET_JS]
|
||||
// --- [INTERACTIVE TASK WIDGET] ---
|
||||
let activeWidgetTasksMap = new Map();
|
||||
|
||||
function renderTaskItemsHtml(tasks, filterStatus) {
|
||||
@@ -134,7 +149,6 @@ function renderInteractiveTaskCard(tasks) {
|
||||
|
||||
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>
|
||||
@@ -142,12 +156,10 @@ function renderInteractiveTaskCard(tasks) {
|
||||
<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">
|
||||
@@ -192,7 +204,6 @@ async function toggleTaskStatusInline(taskId, isChecked) {
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
// Обновляем статус задачи во всех локальных виджетах
|
||||
for (let [wId, state] of activeWidgetTasksMap.entries()) {
|
||||
const targetTask = state.tasks.find(t => t.task_id === taskId);
|
||||
if (targetTask) {
|
||||
@@ -256,7 +267,6 @@ async function addTaskInline(widgetId, inputEl) {
|
||||
|
||||
if (res.ok) {
|
||||
inputEl.value = "";
|
||||
// Мгновенная выгрузка свежего списка без обращения к LLM
|
||||
const tasksRes = await fetch("/api/v1/tasks", {
|
||||
headers: { "Authorization": "Bearer " + token }
|
||||
});
|
||||
@@ -278,7 +288,7 @@ async function addTaskInline(widgetId, inputEl) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- [SECTION 4: CHAT SEND PIPELINE] --- # ANCHOR[CHAT_SEND_PIPELINE]
|
||||
// --- [CHAT SEND PIPELINE] ---
|
||||
async function sendMessage(e) {
|
||||
if (e && e.preventDefault) e.preventDefault();
|
||||
|
||||
@@ -386,6 +396,15 @@ async function sendMessage(e) {
|
||||
${buttonsMarkup}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Если выведены кнопки подтверждения превью — блокируем ввод с клавиатуры
|
||||
if (actionData.type === "PROMPT_PREVIEW") {
|
||||
setInputLocked(true);
|
||||
} else {
|
||||
setInputLocked(false);
|
||||
}
|
||||
} else {
|
||||
setInputLocked(false);
|
||||
}
|
||||
|
||||
let interactiveWidgetHtml = "";
|
||||
@@ -421,8 +440,9 @@ async function sendMessage(e) {
|
||||
`;
|
||||
chatWindow.insertAdjacentHTML("beforeend", errorHtml);
|
||||
chatWindow.scrollTop = chatWindow.scrollHeight;
|
||||
setInputLocked(false);
|
||||
} finally {
|
||||
if (sendBtn) {
|
||||
if (sendBtn && !document.getElementById("user-input")?.disabled) {
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.classList.remove("opacity-50");
|
||||
}
|
||||
@@ -439,7 +459,6 @@ function escapeHtml(text) {
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// --- [SECTION 5: DRAG & DROP AND KEYBOARD LISTENERS] --- # ANCHOR[DRAG_DROP_HANDLERS]
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const input = document.getElementById("user-input");
|
||||
const dropZone = document.getElementById("chat-window")?.parentElement;
|
||||
@@ -452,6 +471,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
===============================================================================
|
||||
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 вложений, блокировка ввода
|
||||
при активных кнопках, вызов онлайн-редактора и отправка правок.
|
||||
===============================================================================
|
||||
*/
|
||||
|
||||
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", "bg-slate-200/50");
|
||||
} else {
|
||||
input.placeholder = "Команда, вопрос или перетащите файл сюда...";
|
||||
input.classList.remove("cursor-not-allowed", "opacity-60", "bg-slate-200/50");
|
||||
}
|
||||
}
|
||||
if (sendBtn) {
|
||||
sendBtn.disabled = isLocked;
|
||||
if (isLocked) {
|
||||
sendBtn.classList.add("opacity-40", "cursor-not-allowed");
|
||||
} else {
|
||||
sendBtn.classList.remove("opacity-40", "cursor-not-allowed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function disableAllActionButtons() {
|
||||
const allBtnContainers = document.querySelectorAll(".action-buttons-container");
|
||||
allBtnContainers.forEach(container => {
|
||||
container.querySelectorAll("button").forEach(btn => {
|
||||
btn.disabled = true;
|
||||
btn.classList.add("opacity-40", "cursor-not-allowed");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleActionButtonClick(text) {
|
||||
if (text === "action:open_editor") {
|
||||
openInlinePromptEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
disableAllActionButtons();
|
||||
setInputLocked(false);
|
||||
const input = document.getElementById("user-input");
|
||||
if (input) {
|
||||
input.value = text;
|
||||
sendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
function openInlinePromptEditor() {
|
||||
const editorContainer = document.getElementById("inline-prompt-editor-container");
|
||||
if (editorContainer) {
|
||||
editorContainer.classList.remove("hidden");
|
||||
const textarea = document.getElementById("inline-prompt-textarea");
|
||||
if (textarea) textarea.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function closeInlinePromptEditor() {
|
||||
const editorContainer = document.getElementById("inline-prompt-editor-container");
|
||||
if (editorContainer) editorContainer.classList.add("hidden");
|
||||
}
|
||||
|
||||
async function saveManualPromptDraft() {
|
||||
const textarea = document.getElementById("inline-prompt-textarea");
|
||||
if (!textarea) return;
|
||||
|
||||
const newText = textarea.value.trim();
|
||||
if (!newText) return;
|
||||
|
||||
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/v1/chat/draft", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer " + token
|
||||
},
|
||||
body: JSON.stringify({
|
||||
session_id: "web_session_main",
|
||||
draft_text: newText
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeInlinePromptEditor();
|
||||
const previewTextEl = document.getElementById("prompt-preview-diff-view");
|
||||
if (previewTextEl) {
|
||||
previewTextEl.innerText = newText;
|
||||
}
|
||||
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();
|
||||
|
||||
const input = document.getElementById("user-input");
|
||||
const chatWindow = document.getElementById("chat-window");
|
||||
const sendBtn = document.getElementById("send-btn");
|
||||
|
||||
if (!input || !chatWindow) return;
|
||||
const text = input.value.trim();
|
||||
|
||||
if (!text && !selectedFile) return;
|
||||
|
||||
disableAllActionButtons();
|
||||
|
||||
let userDisplayHtml = escapeHtml(text);
|
||||
if (selectedFile) {
|
||||
userDisplayHtml = `<div class="font-bold border-b border-indigo-400/40 pb-1 mb-1 text-[11px] flex items-center gap-1.5">
|
||||
<i class="fa-solid fa-file"></i> ${escapeHtml(selectedFile.name)}
|
||||
</div>` + userDisplayHtml;
|
||||
}
|
||||
|
||||
const userMsgHtml = `
|
||||
<div class="flex justify-end mb-3">
|
||||
<div class="bg-indigo-600 text-white rounded-2xl px-4 py-2.5 max-w-2xl text-xs sm:text-sm shadow-sm">
|
||||
${userDisplayHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
chatWindow.insertAdjacentHTML("beforeend", userMsgHtml);
|
||||
|
||||
input.value = "";
|
||||
updateInputHeight(input);
|
||||
chatWindow.scrollTop = chatWindow.scrollHeight;
|
||||
|
||||
if (sendBtn) {
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.classList.add("opacity-50");
|
||||
}
|
||||
|
||||
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
|
||||
const isGuest = typeof IS_GUEST !== 'undefined' ? IS_GUEST : (localStorage.getItem("scud_is_guest") === "true");
|
||||
|
||||
const endpoint = isGuest ? "/api/v1/chat/guest" : "/api/v1/chat";
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("session_id", "web_session_main");
|
||||
formData.append("message", text || "Проанализируй прикрепленный файл");
|
||||
|
||||
if (selectedFile instanceof File) {
|
||||
formData.append("file", selectedFile, selectedFile.name);
|
||||
}
|
||||
|
||||
const headers = {};
|
||||
if (!isGuest && token) {
|
||||
headers["Authorization"] = "Bearer " + token;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: headers,
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (res.status === 401 && !isGuest) {
|
||||
if (typeof logout === 'function') logout();
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const assistantTitle = isGuest ? "Локальная нейросеть (Гость)" : "ИИ-Ассистент SCUD Orion AI";
|
||||
const replyText = data.reply || "Пустой ответ от нейросети";
|
||||
|
||||
let actionButtonsHtml = "";
|
||||
const actionData = data.action_type || data.action;
|
||||
|
||||
if (actionData && actionData.buttons && actionData.buttons.length > 0) {
|
||||
const buttonsMarkup = actionData.buttons.map(btn => {
|
||||
let btnClasses = "bg-slate-100 hover:bg-slate-200 active:bg-slate-300 text-slate-700 border border-slate-300";
|
||||
let iconMarkup = '<i class="fa-solid fa-arrow-right text-[10px] opacity-60"></i>';
|
||||
|
||||
const labelLower = (btn.label || "").toLowerCase();
|
||||
const isPrimary = btn.style === "primary" || labelLower.includes("подтверд") || labelLower.startsWith("да");
|
||||
const isDanger = btn.style === "danger" || labelLower.includes("отмен") || labelLower.startsWith("нет") || labelLower.includes("законч") || labelLower.includes("заверш");
|
||||
const isEdit = labelLower.includes("редактир");
|
||||
|
||||
if (isPrimary) {
|
||||
btnClasses = "bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white shadow-sm";
|
||||
iconMarkup = '<i class="fa-solid fa-check"></i>';
|
||||
} else if (isDanger) {
|
||||
btnClasses = "bg-rose-50 hover:bg-rose-100 active:bg-rose-200 text-rose-700 border border-rose-300";
|
||||
iconMarkup = '<i class="fa-solid fa-xmark"></i>';
|
||||
} else if (isEdit) {
|
||||
btnClasses = "bg-indigo-50 hover:bg-indigo-100 active:bg-indigo-200 text-indigo-700 border border-indigo-300";
|
||||
iconMarkup = '<i class="fa-solid fa-pen-to-square text-xs"></i>';
|
||||
}
|
||||
|
||||
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>
|
||||
`;
|
||||
|
||||
// Блокируем ввод при наличии кнопок
|
||||
setInputLocked(true);
|
||||
} else {
|
||||
setInputLocked(false);
|
||||
}
|
||||
|
||||
let previewEditorHtml = "";
|
||||
if (actionData && actionData.type === "PROMPT_PREVIEW" && actionData.raw_draft) {
|
||||
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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
let interactiveWidgetHtml = "";
|
||||
if (actionData && actionData.type === "TASK_INTERACTIVE_CARD" && typeof renderInteractiveTaskCard === "function") {
|
||||
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>
|
||||
<div class="text-slate-800 text-xs sm:text-sm whitespace-pre-wrap leading-relaxed" id="prompt-preview-diff-view">${replyText}</div>
|
||||
${previewEditorHtml}
|
||||
${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 {
|
||||
const inputEl = document.getElementById("user-input");
|
||||
if (sendBtn && (!inputEl || !inputEl.disabled)) {
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.classList.remove("opacity-50");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- [DRAG & DROP AND KEYBOARD LISTENERS] ---
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const input = document.getElementById("user-input");
|
||||
const dropZone = document.getElementById("chat-window")?.parentElement;
|
||||
const dropOverlay = document.getElementById("drop-overlay");
|
||||
|
||||
if (input) {
|
||||
let historyIndex = -1;
|
||||
let localHistory = JSON.parse(localStorage.getItem("scud_chat_input_history") || "[]");
|
||||
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (input.disabled) return;
|
||||
const text = input.value.trim();
|
||||
if (text) {
|
||||
if (localHistory.length === 0 || localHistory[0] !== text) {
|
||||
localHistory.unshift(text);
|
||||
if (localHistory.length > 50) localHistory.pop();
|
||||
localStorage.setItem("scud_chat_input_history", JSON.stringify(localHistory));
|
||||
}
|
||||
historyIndex = -1;
|
||||
}
|
||||
sendMessage(e);
|
||||
updateInputHeight(input);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "ArrowUp") {
|
||||
const textBeforeCursor = input.value.substring(0, input.selectionStart);
|
||||
const isFirstLine = !textBeforeCursor.includes("\n");
|
||||
|
||||
if (isFirstLine && input.selectionStart === 0 && localHistory.length > 0) {
|
||||
if (historyIndex < localHistory.length - 1) {
|
||||
e.preventDefault();
|
||||
if (historyIndex === -1) {
|
||||
input.dataset.draft = input.value;
|
||||
}
|
||||
historyIndex++;
|
||||
input.value = localHistory[historyIndex];
|
||||
updateInputHeight(input);
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
const textAfterCursor = input.value.substring(input.selectionEnd);
|
||||
const isLastLine = !textAfterCursor.includes("\n");
|
||||
|
||||
if (isLastLine && input.selectionEnd === input.value.length && historyIndex >= 0) {
|
||||
e.preventDefault();
|
||||
if (historyIndex > 0) {
|
||||
historyIndex--;
|
||||
input.value = localHistory[historyIndex];
|
||||
} else {
|
||||
historyIndex = -1;
|
||||
input.value = input.dataset.draft || "";
|
||||
}
|
||||
updateInputHeight(input);
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (dropZone && dropOverlay) {
|
||||
["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, false);
|
||||
});
|
||||
|
||||
["dragenter", "dragover"].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, () => {
|
||||
dropOverlay.classList.remove("hidden");
|
||||
dropOverlay.classList.add("flex");
|
||||
}, false);
|
||||
});
|
||||
|
||||
["dragleave", "drop"].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, (e) => {
|
||||
if (eventName === "drop" || e.target === dropZone || !dropZone.contains(e.relatedTarget)) {
|
||||
dropOverlay.classList.add("hidden");
|
||||
dropOverlay.classList.remove("flex");
|
||||
}
|
||||
}, false);
|
||||
});
|
||||
|
||||
dropZone.addEventListener("drop", (e) => {
|
||||
const dt = e.dataTransfer;
|
||||
const files = dt.files;
|
||||
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
handleFileSelect({ target: { files: [file] } });
|
||||
|
||||
const fileInput = document.getElementById("file-input");
|
||||
if (fileInput) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
fileInput.files = dataTransfer.files;
|
||||
}
|
||||
}
|
||||
}, false);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
===============================================================================
|
||||
FILE: modules/web_api/static/js/chat/task_widget.js
|
||||
ROLE: Генеративный UI интерактивных карточек задач внутри диалога чата
|
||||
(фильтры, inline-чекбоксы статусов, быстрое добавление и удаление).
|
||||
===============================================================================
|
||||
*/
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user