Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f481fffdb7 | ||
|
|
fc1a608a5f | ||
|
|
a622fd98d2 | ||
|
|
286355e4c0 | ||
|
|
2fa151345f | ||
|
|
8a3f3c749f | ||
|
|
a47514648c | ||
|
|
cc6b80d714 | ||
|
|
cda2705334 | ||
|
|
99b32720b6 |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
# 🏛️ Архитектурный план: Двухуровневая очистка контекста и управление чатами
|
||||
|
||||
## 🎯 Концептуальная суть
|
||||
|
||||
* **Для пользователя (UI):** Полноценный, наглядный интерфейс с интерактивными превью, карточками и кнопками действий.
|
||||
* **Для нейросети (LLM):** Идеально «стерильное» окно контекста, очищенное от тяжелых временных черновиков (`is_ephemeral = 0`), что исключает галлюцинации и принуждает модель использовать функции SQLite (*Single Source of Truth*).
|
||||
* **Событийно-счетчиковая очистка:** Временные данные хранятся ровно столько, сколько нужно для завершения задачи или уточняющих вопросов, после чего безопасно зачищаются.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Пошаговый план реализации (Step-by-Step)
|
||||
|
||||
### **Шаг 1. Модификация схемы Базы Данных (SQLite)**
|
||||
|
||||
* Добавление флага эфемеральности в таблицу `chat_messages`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE chat_messages ADD COLUMN is_ephemeral INTEGER DEFAULT 0;
|
||||
```
|
||||
|
||||
* `is_ephemeral = 0` — постоянные сообщения (вопросы, итоговые отклики, фиксированные задачи).
|
||||
* `is_ephemeral = 1` — временные рабочие выводы инструментов (превью системного промпта, промежуточные срезы/снапшоты, черновики).
|
||||
|
||||
---
|
||||
|
||||
### **Шаг 2. Разделение каналов истории и задел под изолированные сессии (Multi-Chat)**
|
||||
|
||||
* **Канал UI (Пользователь):** Функция выгрузки истории для веб-интерфейса отдаёт все сообщения (`is_ephemeral = 0` и `1`), обеспечивая прозрачность работы.
|
||||
* **Канал LLM (Оллама API):** В функцию `db_get_chat_history` передается жесткий фильтр:
|
||||
|
||||
```sql
|
||||
SELECT role, content
|
||||
FROM chat_messages
|
||||
WHERE session_id = ? AND is_ephemeral = 0
|
||||
ORDER BY id DESC
|
||||
LIMIT 12;
|
||||
```
|
||||
|
||||
* **Изолированные чаты (Перспектива):**
|
||||
* Создание сущности чатов (`chat_id` / `session_id`).
|
||||
* У каждого чата свой изолированный контекст.
|
||||
* Ручное удаление чата физически очищает все связанные записи в `chat_messages` и `session_states`.
|
||||
|
||||
---
|
||||
|
||||
### **Шаг 3. Интеллектуальный TTL-счетчик временного контекста (Auto-TTL)**
|
||||
|
||||
* Временный контекст (`is_ephemeral = 1`) **НЕ** сбрасывается мгновенно, если пользователь задает уточняющие вопросы в рамках той же темы.
|
||||
* Вводится счетчик сопутствующих сообщений (например, $N = 3..5$ сообщений).
|
||||
* Если оператор задает $N$ сообщений подряд, не относящихся к активному инструменту, или переключается на другой инструмент — временный контекст автоматически зачищается/деактивируется.
|
||||
|
||||
---
|
||||
|
||||
### **Шаг 4. UX-завершение транзакции (Кнопки «Да / Нет»)**
|
||||
|
||||
1. После выполнения операции (например, успешной перезаписи системного промпта) ассистент отдает флаг `transaction_completed: true`.
|
||||
2. На фронтенде выезжает блок подтверждения результатов:
|
||||
> «Желаемый результат получен?»
|
||||
> `[ 👍 Да, закрыть ]` `[ 👎 Нет, продолжить ]`
|
||||
3. **Клик «Да»:** Отправляет триггер на сервер $
|
||||
ightarrow$ бэкенд выполняет:
|
||||
```sql
|
||||
DELETE FROM chat_messages WHERE session_id = ? AND is_ephemeral = 1;
|
||||
```
|
||||
и очищает `session_states`.
|
||||
4. **Клик «Нет»:** Оставляет промежуточный контекст активным для продолжения редактирования.
|
||||
|
||||
---
|
||||
|
||||
## 🧹 Подготовка к старту
|
||||
|
||||
1. Предварительная зачистка кода от старых регулярных выражений и системных костылей в `llm/agent.py` и `llm/core/tool_injector.py`.
|
||||
@@ -0,0 +1,19 @@
|
||||
# 🏛️ Архитектурный план: Двухуровневая очистка контекста и управление чатами
|
||||
|
||||
## 🎯 Концептуальная суть
|
||||
* **Для пользователя (UI):** Полноценный, наглядный интерфейс с интерактивными превью, карточками и кнопками действий.
|
||||
* **Для нейросети (LLM):** Идеально «стерильное» окно контекста, очищенное от тяжелых временных черновиков (`is_ephemeral = 0`), что исключает галлюцинации и принуждает модель использовать функции SQLite (Single Source of Truth).
|
||||
* **Событийно-счетчиковая очистка:** Временные данные хранятся ровно столько, сколько нужно для завершения задачи или уточняющих вопросов, после чего безопасно зачищаются.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Пошаговый план реализации (Step-by-Step)
|
||||
|
||||
### **Шаг 1. Модификация схемы Базы Данных (SQLite)**
|
||||
* Добавление флага эфемеральности в таблицу `chat_messages`:
|
||||
```sql
|
||||
ALTER TABLE chat_messages ADD COLUMN is_ephemeral INTEGER DEFAULT 0;
|
||||
is_ephemeral = 0 — постоянные сообщения (вопросы, итоговые отклики, фиксированные задачи).is_ephemeral = 1 — временные рабочие выводы инструментов (превью системного промпта, промежуточные срезы/снапшоты, черновики).Шаг 2. Разделение каналов истории и задел под изолированные сессии (Multi-Chat)Канал UI (Пользователь): Функция выгрузки истории для веб-интерфейса отдаёт все сообщения (is_ephemeral = 0 и 1), обеспечивая прозрачность работы.Канал LLM (Оллама API): В функцию db_get_chat_history передается жесткий фильтр:SQLSELECT role, content FROM chat_messages
|
||||
WHERE session_id = ? AND is_ephemeral = 0
|
||||
ORDER BY id DESC LIMIT 12;
|
||||
Изолированные чаты (Перспектива):Создание сущности чатов (chat_id / session_id).У каждого чата свой изолированный контекст.Ручное удаление чата физически очищает все связанные записи в chat_messages и session_states.Шаг 3. Интеллектуальный TTL-счетчик временного контекста (Auto-TTL)Временный контекст (is_ephemeral = 1) НЕ сбрасывается мгновенно, если пользователь задает уточняющие вопросы в рамках той же темы.Вводится счетчик сопутствующих сообщений (например, N = 3..5 сообщений).Если оператор задает $N$ сообщений подряд, не относящихся к активному инструменту, или переключается на другой инструмент — временный контекст автоматически зачищается/деактивируется.Шаг 4. UX-завершение транзакции (Кнопки «Да / Нет»)После выполнения операции (например, успешной перезаписи системного промпта) ассистент отдает флаг transaction_completed: true.На фронтенде выезжает блок подтверждения результатов:«Желаемый результат получен?»[ 👍 Да, закрыть ] [ 👎 Нет, продолжить ]Клик «Да»: Отправляет триггер на сервер $\rightarrow$ бэкенд выполняет DELETE FROM chat_messages WHERE session_id = ? AND is_ephemeral = 1 и очищает session_states.Клик «Нет»: Оставляет промежуточный контекст активным для продолжения редактирования.🧹 Подготовка к стартуПредварительная зачистка кода от старых регулярных выражений и системных костылей в llm/agent.py и llm/core/tool_injector.py.Выполнение scripts/clear_history.py для старта с чистого листа.
|
||||
+22
-2
@@ -39,9 +39,29 @@ def init_db():
|
||||
);
|
||||
""")
|
||||
|
||||
# Добавление новой таблицы для сессионных состояний
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS session_states (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
state_type TEXT NOT NULL,
|
||||
pending_data TEXT NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL, -- 'user', 'assistant', 'tool'
|
||||
content TEXT NOT NULL, -- текст сообщения
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"[✓] Единая база данных SQLite ({DB_NAME}) успешно проверкой инициализирована!")
|
||||
print(f"[✓] Единая база данных SQLite ({DB_NAME}) успешно инициализирована!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_db()
|
||||
init_db()
|
||||
+260
-85
@@ -1,130 +1,305 @@
|
||||
"""
|
||||
===============================================================================
|
||||
MODULE: llm/agent.py
|
||||
ROLE: Главный оркестратор агентской логики.
|
||||
===============================================================================
|
||||
"""
|
||||
import re
|
||||
import json
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from typing import List, Dict, Any, Tuple
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
|
||||
from .db_tools import (
|
||||
db_get_active_system_prompt,
|
||||
db_add_system_prompt,
|
||||
db_add_system_prompt,
|
||||
db_confirm_prompt_preview,
|
||||
db_get_tasks,
|
||||
db_update_task_status,
|
||||
db_delete_task,
|
||||
db_add_task,
|
||||
db_get_rules
|
||||
db_get_rules,
|
||||
db_set_session_state,
|
||||
db_get_session_state,
|
||||
db_get_snapshots,
|
||||
db_delete_snapshots,
|
||||
db_clear_session_state,
|
||||
db_get_current_server_time,
|
||||
db_save_chat_message,
|
||||
db_get_chat_history,
|
||||
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, inject_tools_if_needed
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
logger = logging.getLogger("SCUD_AGENT")
|
||||
|
||||
OLLAMA_URL = "http://192.168.11.3:11434/api/chat"
|
||||
MODEL_NAME = "qwen2.5:14b"
|
||||
TEXT_MODEL = "qwen2.5:14b"
|
||||
VISION_MODEL = "qwen2.5vl:7b-q8_0"
|
||||
|
||||
def process_chat_message(user_message: str, chat_history: List[Dict[str, Any]] = None) -> Tuple[str, List[Dict[str, Any]]]:
|
||||
if chat_history is None:
|
||||
chat_history = []
|
||||
def process_chat_message(
|
||||
user_id: int,
|
||||
user_message: str,
|
||||
file_context: str = "",
|
||||
image_b64: Optional[str] = None,
|
||||
chat_history: List[Dict[str, Any]] = None,
|
||||
session_id: str = "web_session_main"
|
||||
) -> Tuple[str, List[Dict[str, Any]]]:
|
||||
logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}")
|
||||
|
||||
full_user_content = user_message
|
||||
if file_context:
|
||||
full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}"
|
||||
|
||||
current_now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
user_lower = user_message.lower().strip()
|
||||
|
||||
if any(phrase in user_lower for phrase in ["системный промпт", "покажи промпт", "промпт системы", "промпт из базы"]):
|
||||
prompt_text = db_get_active_system_prompt()
|
||||
formatted_text = f"⚙️ Актуальный системный промпт ИИ (из базы SQLite):\n\n{prompt_text}"
|
||||
return formatted_text, chat_history + [{"role": "user", "content": user_message}, {"role": "assistant", "content": formatted_text}]
|
||||
|
||||
if any(phrase in user_lower for phrase in ["базу знаний", "база знаний", "покажи правила", "инструкции ии"]):
|
||||
rules = db_get_rules()
|
||||
formatted_text = "🧠 База знаний ИИ (ai_knowledge_base):\n\n" + "\n\n".join([f"• {r.get('id')}. {r.get('rule_text')}" for r in rules]) if rules else "База знаний пуста."
|
||||
return formatted_text, chat_history + [{"role": "user", "content": user_message}, {"role": "assistant", "content": formatted_text}]
|
||||
db_history = db_get_chat_history(session_id, limit=20)
|
||||
db_save_chat_message(session_id, "user", full_user_content)
|
||||
|
||||
dynamic_prompt_text = db_get_active_system_prompt()
|
||||
system_prompt = {
|
||||
"role": "system",
|
||||
"content": f"Текущая дата и время сервера: {current_now}.\n\n{dynamic_prompt_text}"
|
||||
}
|
||||
calendar_context = get_dynamic_calendar_context()
|
||||
|
||||
session_state = db_get_session_state(session_id)
|
||||
preview_status_note = ""
|
||||
if session_state and session_state.get("state_type") == "PROMPT_PREVIEW":
|
||||
preview_status_note = (
|
||||
"\n\n[АКТИВНО ПРЕВЬЮ ПРОМПТА: Ожидается решение пользователя]. "
|
||||
"Если пользователь согласен (пишет 'подтверждаю', 'да', 'сохраняй') — вызывай db_confirm_prompt_preview. "
|
||||
"Если отказывается — вызывай db_cancel_prompt_preview."
|
||||
)
|
||||
|
||||
messages = [system_prompt] + chat_history + [{"role": "user", "content": user_message}]
|
||||
user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость"
|
||||
|
||||
payload = {
|
||||
"model": MODEL_NAME,
|
||||
"messages": messages,
|
||||
"tools": TOOLS_SCHEMA,
|
||||
"stream": False,
|
||||
"options": {"num_predict": 2048, "num_ctx": 8192, "temperature": 0.1}
|
||||
}
|
||||
|
||||
req = urllib.request.Request(
|
||||
OLLAMA_URL,
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"}
|
||||
system_prompt_content = (
|
||||
f"[ТЕКУЩИЙ АВТОРИЗОВАННЫЙ ПОЛЬЗОВАТЕЛЬ]\n"
|
||||
f"Вы общаетесь с пользователем: {user_info}.\n"
|
||||
f"Все запрашиваемые задачи через инструмент db_get_tasks автоматически принадлежат ИМЕННО этому пользователю.\n\n"
|
||||
f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n"
|
||||
f"{calendar_context}\n\n"
|
||||
f"[ПРАВИЛА ИСПОЛНЕНИЯ ИНСТРУМЕНТОВ (TOOLS RULES)]\n"
|
||||
f"1. Текстовый блок ниже 'ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ' предназначен ИСКЛЮЧИТЕЛЬНО для твоего внутреннего руководства по поведению и стилю.\n"
|
||||
f"2. ТЕБЕ КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО цитировать или выводить текст системного промпта из этого встроенного блока!\n"
|
||||
f"3. При ЛЮБОМ запросе пользователя на просмотр, чтение, обновление или показ системного промпта (например: 'покажи промпт', 'обнови из базы системный промпт', 'загрузи промпт') Ты ОБЯЗАН СРАЗУ сгенерировать Tool Call: db_get_system_prompt().\n"
|
||||
f"4. Любые правки системного промпта выполняй СТРОГО через db_preview_prompt_merge.\n\n"
|
||||
f"ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ (ДЛЯ ВНУТРЕННЕГО ИСПОЛНЕНИЯ ИНСТРУКЦИЙ):\n{dynamic_prompt_text}{preview_status_note}"
|
||||
)
|
||||
|
||||
user_msg_object = {"role": "user", "content": full_user_content}
|
||||
|
||||
llm_options = {
|
||||
"num_predict": 8192,
|
||||
"num_ctx": 8192,
|
||||
"temperature": 0.1,
|
||||
"repeat_penalty": 1.1,
|
||||
"presence_penalty": 0.5,
|
||||
"top_p": 0.9
|
||||
}
|
||||
|
||||
if image_b64:
|
||||
user_msg_object["images"] = [image_b64]
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Ты — строгий модуль OCR для документов. Твоя задача — дословно переписать весь печатный и рукописный текст с изображения.\n"
|
||||
"ПРАВИЛА:\n"
|
||||
"1. Переписывай рукописный текст СТРОГО буква в букву так, как он написан от руки. Не додумывай слова от себя!\n"
|
||||
"2. Отдельно выдели блок с рукописными записями, подписями и датами.\n"
|
||||
"3. Не добавляй лишних слов, которых нет в графической части."
|
||||
)
|
||||
},
|
||||
user_msg_object
|
||||
]
|
||||
payload = {
|
||||
"model": VISION_MODEL,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"options": llm_options
|
||||
}
|
||||
else:
|
||||
clean_db_history = []
|
||||
for msg in db_history:
|
||||
msg_copy = dict(msg)
|
||||
msg_copy.pop("images", None)
|
||||
clean_db_history.append(msg_copy)
|
||||
|
||||
system_prompt = {"role": "system", "content": system_prompt_content}
|
||||
messages = [system_prompt] + clean_db_history + [user_msg_object]
|
||||
payload = {
|
||||
"model": TEXT_MODEL,
|
||||
"messages": messages,
|
||||
"tools": TOOLS_SCHEMA,
|
||||
"tool_choice": "auto",
|
||||
"stream": False,
|
||||
"options": llm_options
|
||||
}
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
OLLAMA_URL,
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req) as response:
|
||||
res_data = json.loads(response.read().decode("utf-8"))
|
||||
msg = res_data.get("message", {})
|
||||
tool_calls = msg.get("tool_calls", [])
|
||||
content_str = msg.get("content", "").strip().replace("**", "")
|
||||
raw_text_content = msg.get("content", "")
|
||||
|
||||
if not tool_calls and "name" in content_str and "db_" in content_str:
|
||||
try:
|
||||
start_idx = content_str.find("{")
|
||||
end_idx = content_str.rfind("}") + 1
|
||||
if start_idx != -1 and end_idx != -1:
|
||||
parsed = json.loads(content_str[start_idx:end_idx])
|
||||
if "name" in parsed:
|
||||
tool_calls = [{"function": parsed}]
|
||||
except Exception:
|
||||
pass
|
||||
# Активируем внешний инжектор
|
||||
tool_calls = inject_tools_if_needed(user_message, raw_text_content, tool_calls)
|
||||
logger.info(f"Ответ от Ollama получен. Tool calls: {bool(tool_calls)}")
|
||||
|
||||
if tool_calls:
|
||||
messages.append(msg)
|
||||
|
||||
for tool in tool_calls:
|
||||
fn_name = tool["function"]["name"]
|
||||
fn_args = tool["function"].get("arguments", {})
|
||||
logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}")
|
||||
tool_result_content = ""
|
||||
|
||||
if fn_name == "db_get_tasks":
|
||||
tasks = db_get_tasks()
|
||||
if not tasks:
|
||||
formatted_text = "Список задач пуст."
|
||||
if fn_name == "db_get_snapshots":
|
||||
date_arg = fn_args.get("date_str")
|
||||
snapshots_res = db_get_snapshots(session_id=session_id, date_str=date_arg, original_user_message=user_message)
|
||||
tool_result_content = json.dumps(snapshots_res, ensure_ascii=False)
|
||||
|
||||
elif fn_name == "db_get_current_server_time":
|
||||
time_res = db_get_current_server_time()
|
||||
tool_result_content = json.dumps(time_res, ensure_ascii=False)
|
||||
|
||||
elif fn_name == "db_get_tasks":
|
||||
tasks = db_get_tasks(user_id)
|
||||
tool_result_content = json.dumps(tasks, ensure_ascii=False)
|
||||
|
||||
elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]:
|
||||
prompt_content = db_get_active_system_prompt()
|
||||
tool_result_content = json.dumps({"system_prompt": prompt_content}, ensure_ascii=False)
|
||||
|
||||
elif fn_name == "db_get_stats":
|
||||
tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False)
|
||||
|
||||
elif fn_name == "db_get_anomalies":
|
||||
limit_arg = fn_args.get("limit", 100)
|
||||
date_arg = fn_args.get("date_str")
|
||||
tool_result_content = json.dumps(db_get_anomalies(limit=limit_arg, date_str=date_arg), 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_delete_snapshots":
|
||||
snap_id = fn_args.get("snapshot_id")
|
||||
day_arg = fn_args.get("day_str")
|
||||
tool_result_content = json.dumps(db_delete_snapshots(snapshot_id=snap_id, day_str=day_arg), ensure_ascii=False)
|
||||
|
||||
elif fn_name == "db_get_reference":
|
||||
cat_arg = fn_args.get("category")
|
||||
tool_result_content = json.dumps(db_get_reference(category=cat_arg), ensure_ascii=False)
|
||||
|
||||
# === ВОССТАНОВЛЕННАЯ ЛОГИКА ПРЕВЬЮ-МЕРДЖА ===
|
||||
elif fn_name == "db_preview_prompt_merge":
|
||||
proposed_text = fn_args.get("prompt_text") or ""
|
||||
current_prompt = db_get_active_system_prompt()
|
||||
user_msg_lower = user_message.lower()
|
||||
|
||||
# 1. Запрос на удаление
|
||||
if "удали" in user_msg_lower or "стереть" in user_msg_lower:
|
||||
target_num = re.search(r'\d+(\.\d+)?', user_message)
|
||||
target_str = target_num.group(0) if target_num else ""
|
||||
lines = current_prompt.splitlines()
|
||||
new_lines = [line for line in lines if not (target_str and f"{target_str}." in line.strip())]
|
||||
final_prompt = "\n".join(new_lines)
|
||||
|
||||
# 2. Модель прислала уже полный текст без многоточий
|
||||
elif len(proposed_text) > 500 and "..." not in proposed_text:
|
||||
final_prompt = proposed_text
|
||||
|
||||
# 3. Добавление/вкрап нового пункта
|
||||
else:
|
||||
tasks_sorted = sorted(tasks, key=lambda x: x.get('task_id', ''), reverse=True)
|
||||
formatted_text = f"📋 Реестр задач SCUD Orion AI (Всего: {len(tasks_sorted)}):\n\n"
|
||||
for t in tasks_sorted:
|
||||
due = f" (до {t['due_date']})" if t.get('due_date') else ""
|
||||
formatted_text += f"• {t.get('task_id')} — {t.get('title')}\n"
|
||||
formatted_text += f" - Приоритет: {t.get('priority') or 'MEDIUM'} | Статус: {t.get('status') or 'BACKLOG'} | Модуль: {t.get('module') or 'general'}{due}\n\n"
|
||||
return formatted_text, chat_history + [{"role": "user", "content": user_message}, {"role": "assistant", "content": formatted_text}]
|
||||
clean_item = proposed_text.strip()
|
||||
lines = current_prompt.splitlines()
|
||||
new_lines = []
|
||||
inserted = False
|
||||
for line in lines:
|
||||
new_lines.append(line)
|
||||
if "3.3." in line and not inserted:
|
||||
item_str = clean_item if clean_item.startswith("3.") else f"3.4. {clean_item}"
|
||||
new_lines.append(f" {item_str}")
|
||||
inserted = True
|
||||
if not inserted:
|
||||
new_lines.append(f" {clean_item}")
|
||||
final_prompt = "\n".join(new_lines)
|
||||
|
||||
db_set_session_state(session_id, "PROMPT_PREVIEW", final_prompt)
|
||||
preview_reply = f"Подготовлено превью изменений системного промпта:\n\n{final_prompt}\n\nДля применения изменений нажмите «Подтвердить» или «Отменить»."
|
||||
|
||||
# Сохраняем краткую пометку, не забивая контекст LLM
|
||||
db_save_chat_message(session_id, "assistant", "Подготовлено превью изменений системного промпта. Ожидается решение пользователя.")
|
||||
return clean_raw_tool_tags(preview_reply), db_get_chat_history(session_id)
|
||||
|
||||
elif fn_name == "db_confirm_prompt_preview":
|
||||
res = db_confirm_prompt_preview(session_id)
|
||||
confirm_reply = "Изменения системного промпта успешно применены в базе данных!" if res.get("status") == "success" else res.get("message", "Ошибка применения.")
|
||||
db_save_chat_message(session_id, "assistant", confirm_reply)
|
||||
return clean_raw_tool_tags(confirm_reply), db_get_chat_history(session_id)
|
||||
|
||||
elif fn_name == "db_cancel_prompt_preview":
|
||||
db_clear_session_state(session_id)
|
||||
cancel_reply = "Превью изменений системного промпта отменено."
|
||||
db_save_chat_message(session_id, "assistant", cancel_reply)
|
||||
return clean_raw_tool_tags(cancel_reply), db_get_chat_history(session_id)
|
||||
|
||||
elif fn_name == "db_get_rules":
|
||||
rules = db_get_rules()
|
||||
formatted_text = "🧠 База знаний ИИ (ai_knowledge_base):\n\n" + "\n\n".join([f"• {r.get('id')}. {r.get('rule_text')}" for r in rules]) if rules else "База знаний пуста."
|
||||
return formatted_text, chat_history + [{"role": "user", "content": user_message}, {"role": "assistant", "content": formatted_text}]
|
||||
|
||||
elif fn_name == "db_get_system_prompt":
|
||||
prompt_text = db_get_active_system_prompt()
|
||||
formatted_text = f"⚙️ Актуальный системный промпт ИИ (из базы SQLite):\n\n{prompt_text}"
|
||||
return formatted_text, chat_history + [{"role": "user", "content": user_message}, {"role": "assistant", "content": formatted_text}]
|
||||
|
||||
elif fn_name == "db_add_system_prompt":
|
||||
res = db_add_system_prompt(name=fn_args.get("name", "main_agent"), prompt_text=fn_args.get("prompt_text", ""))
|
||||
formatted_text = res.get("message", "Промпт обновлен.")
|
||||
return formatted_text, chat_history + [{"role": "user", "content": user_message}, {"role": "assistant", "content": formatted_text}]
|
||||
tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False)
|
||||
|
||||
elif fn_name == "db_add_task":
|
||||
res = db_add_task(module=fn_args.get("module", "general"), title=fn_args.get("title"), priority=fn_args.get("priority", "MEDIUM"), due_date=fn_args.get("due_date"))
|
||||
formatted_text = f"[✓] Задача {res.get('task_id', 'TASK')} успешно создана!" if "status" in res else f"❌ Ошибка: {res.get('error')}"
|
||||
return formatted_text, chat_history + [{"role": "user", "content": user_message}, {"role": "assistant", "content": formatted_text}]
|
||||
res = db_add_task(user_id=user_id, module=fn_args.get("module", "general"), title=fn_args.get("title"), priority=fn_args.get("priority", "MEDIUM"), due_date=fn_args.get("due_date"))
|
||||
tool_result_content = json.dumps(res, ensure_ascii=False)
|
||||
|
||||
elif fn_name == "db_update_task_status":
|
||||
res = db_update_task_status(task_id=fn_args.get("task_id"), status=fn_args.get("status"), due_date=fn_args.get("due_date"))
|
||||
formatted_text = f"[✓] Статус задачи {fn_args.get('task_id')} обновлен!" if "status" in res else f"❌ Ошибка: {res.get('error')}"
|
||||
return formatted_text, chat_history + [{"role": "user", "content": user_message}, {"role": "assistant", "content": formatted_text}]
|
||||
res = db_update_task_status(user_id=user_id, task_id=str(fn_args.get("task_id")), status=fn_args.get("status", "COMPLETED"), due_date=fn_args.get("due_date"))
|
||||
tool_result_content = json.dumps(res, ensure_ascii=False)
|
||||
|
||||
elif fn_name == "db_delete_task":
|
||||
task_id_to_del = fn_args.get("task_id", "").upper()
|
||||
res = db_delete_task(task_id=task_id_to_del)
|
||||
formatted_text = f"[✓] Задача {task_id_to_del} успешно удалена из базы!" if "status" in res else f"❌ Ошибка: {res.get('error')}"
|
||||
return formatted_text, chat_history + [{"role": "user", "content": user_message}, {"role": "assistant", "content": formatted_text}]
|
||||
res = db_delete_task(user_id=user_id, task_id=str(fn_args.get("task_id", "")).upper())
|
||||
tool_result_content = json.dumps(res, ensure_ascii=False)
|
||||
|
||||
return content_str, chat_history + [{"role": "user", "content": user_message}, {"role": "assistant", "content": content_str}]
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"content": tool_result_content
|
||||
})
|
||||
|
||||
except urllib.error.URLError as e:
|
||||
return f"Ошибка связи с Ollama ({OLLAMA_URL}): {e}", chat_history
|
||||
second_payload = {
|
||||
"model": TEXT_MODEL,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"options": llm_options
|
||||
}
|
||||
sec_req = urllib.request.Request(
|
||||
OLLAMA_URL,
|
||||
data=json.dumps(second_payload).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(sec_req) as sec_response:
|
||||
sec_res_data = json.loads(sec_response.read().decode("utf-8"))
|
||||
raw_content = sec_res_data.get("message", {}).get("content", "").strip().replace("**", "")
|
||||
final_content = clean_raw_tool_tags(clean_output(raw_content))
|
||||
db_save_chat_message(session_id, "assistant", final_content)
|
||||
return final_content, db_get_chat_history(session_id)
|
||||
|
||||
raw_str = msg.get("content", "").strip().replace("**", "")
|
||||
content_str = clean_raw_tool_tags(clean_output(raw_str))
|
||||
final_reply = content_str or "Запрос обработан."
|
||||
db_save_chat_message(session_id, "assistant", final_reply)
|
||||
return final_reply, db_get_chat_history(session_id)
|
||||
|
||||
except Exception as ex:
|
||||
logger.exception(f"Непредвиденная ошибка: {ex}")
|
||||
error_reply = f"Внутренняя ошибка сервера: {ex}"
|
||||
return error_reply, db_get_chat_history(session_id)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
Вспомогательные утилиты динамического календаря и парсинга дат.
|
||||
"""
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
DAYS_RU = [
|
||||
"понедельник", "вторник", "среда", "четверг",
|
||||
"пятница", "суббота", "воскресенье"
|
||||
]
|
||||
|
||||
def parse_relative_date_ru(text: str) -> str:
|
||||
now = datetime.now()
|
||||
text_lower = text.lower() if text else ""
|
||||
|
||||
match = re.search(r'(\d{2}\.\d{2}\.\d{4})', text)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
if "вчера" in text_lower:
|
||||
return (now - timedelta(days=1)).strftime("%d.%m.%Y")
|
||||
elif "позавчера" in text_lower:
|
||||
return (now - timedelta(days=2)).strftime("%d.%m.%Y")
|
||||
elif "сегодня" in text_lower:
|
||||
return now.strftime("%d.%m.%Y")
|
||||
|
||||
return (now - timedelta(days=1)).strftime("%d.%m.%Y")
|
||||
|
||||
def get_dynamic_calendar_context() -> str:
|
||||
now = datetime.now()
|
||||
current_wd = now.weekday()
|
||||
|
||||
lines = [
|
||||
f"СЕГОДНЯ: {DAYS_RU[current_wd].upper()}, {now.strftime('%d.%m.%Y')} (время сервера: {now.strftime('%H:%M:%S')}).",
|
||||
"\nСПРАВОЧНИК ДАТ ДЛЯ ОТВЕТОВ (БЕРИ ДАТЫ СТРОГО ОТСЮДА):",
|
||||
f"• Сегодня: {now.strftime('%d.%m.%Y')} ({DAYS_RU[current_wd]})",
|
||||
f"• Вчера: {(now - timedelta(days=1)).strftime('%d.%m.%Y')} ({DAYS_RU[(current_wd - 1) % 7]})",
|
||||
f"• Позавчера: {(now - timedelta(days=2)).strftime('%d.%m.%Y')} ({DAYS_RU[(current_wd - 2) % 7]})",
|
||||
"\nПрошедшие дни недели:"
|
||||
]
|
||||
|
||||
for days_back in range(1, 8):
|
||||
dt = now - timedelta(days=days_back)
|
||||
day_name = DAYS_RU[dt.weekday()]
|
||||
|
||||
if days_back == 7:
|
||||
label = f"Прошлый {day_name}" if dt.weekday() in [0, 1, 3, 6] else f"Прошлая {day_name}"
|
||||
lines.append(f"• {label} (ровно неделю назад): {dt.strftime('%d.%m.%Y')}")
|
||||
else:
|
||||
label = f"Ближайший прошедший {day_name}" if dt.weekday() in [0, 1, 3, 6] else f"Ближайшая прошедшая {day_name}"
|
||||
lines.append(f"• {label} / {day_name}: {dt.strftime('%d.%m.%Y')}")
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
Модуль чистки сырых тегов Ollama и перехвата Tool-вызовов (Tool Injector).
|
||||
"""
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
from typing import List, Dict, Any
|
||||
|
||||
logger = logging.getLogger("TOOL_INJECTOR")
|
||||
|
||||
def clean_raw_tool_tags(text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
text = re.sub(r'\{"name":\s*"db_[^}]+\}\s*(</tool_call>)?', '', text)
|
||||
text = re.sub(r'<tool_call>.*?</tool_call>', '', text, flags=re.DOTALL)
|
||||
text = re.sub(r'</tool_call>\w*\[\]\(\)', '', text)
|
||||
text = re.sub(r'</tool_call>', '', text)
|
||||
return text.strip()
|
||||
|
||||
def clean_raw_tool_tags(text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
text = re.sub(r'\{"name":\s*"db_[^}]+\}\s*(</tool_call>)?', '', text)
|
||||
text = re.sub(r'<tool_call>.*?</tool_call>', '', text, flags=re.DOTALL)
|
||||
text = re.sub(r'</tool_call>\w*\[\]\(\)', '', text)
|
||||
text = re.sub(r'</tool_call>', '', text)
|
||||
return text.strip()
|
||||
|
||||
def clean_output(text: str) -> str:
|
||||
if not text:
|
||||
return text
|
||||
artifacts = ["почемучто", "почто", "почему что"]
|
||||
lower_text = text.lower()
|
||||
for art in artifacts:
|
||||
if lower_text.startswith(art):
|
||||
text = text[len(art):].lstrip(",.!?:; -")
|
||||
return text.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:
|
||||
return tool_calls
|
||||
|
||||
# Если Ollama отдала JSON-структуру вызова функции как простой текст — помогаем распарсить
|
||||
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:
|
||||
pass
|
||||
|
||||
return tool_calls
|
||||
|
||||
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
|
||||
|
||||
# Единственная задача Python — поймать JSON, если модель написала его текстом
|
||||
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:
|
||||
pass
|
||||
|
||||
return tool_calls
|
||||
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
Модуль подключения к SQLite.
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
DB_PATH = "/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db"
|
||||
|
||||
def get_db_connection() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(DB_PATH, timeout=30.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode = WAL;")
|
||||
conn.execute("PRAGMA synchronous = NORMAL;")
|
||||
return conn
|
||||
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Модуль хранения и загрузки истории сообщений чата.
|
||||
"""
|
||||
from typing import List, Dict, Any
|
||||
from .connection import get_db_connection
|
||||
|
||||
def db_save_chat_message(session_id: str, role: str, content: str):
|
||||
if not content:
|
||||
return
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO chat_messages (session_id, role, content, created_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
""", (session_id, role, content))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def db_get_chat_history(session_id: str, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT role, content FROM chat_messages
|
||||
WHERE session_id = ?
|
||||
ORDER BY id DESC LIMIT ?
|
||||
""", (session_id, limit))
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
return [{"role": r["role"], "content": r["content"]} for r in reversed(rows)]
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
Модуль системных промптов, сессионных состояний и превью.
|
||||
"""
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
from .connection import get_db_connection
|
||||
|
||||
logger = logging.getLogger("DB_PROMPTS")
|
||||
|
||||
def db_get_active_system_prompt() -> str:
|
||||
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 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()
|
||||
|
||||
logger.info("Системный промпт успешно сохранен и применен в базе данных.")
|
||||
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]]:
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT id, rule_text FROM ai_knowledge_base ORDER BY id ASC")
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def db_set_session_state(session_id: str, state_type: str, data: str):
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO session_states (session_id, state_type, pending_data, updated_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
state_type = excluded.state_type,
|
||||
pending_data = excluded.pending_data,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""", (session_id, state_type, data))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def db_get_session_state(session_id: str) -> Optional[Dict[str, Any]]:
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT state_type, pending_data FROM session_states WHERE session_id = ?", (session_id,))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else None
|
||||
|
||||
def db_clear_session_state(session_id: str):
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,))
|
||||
conn.commit()
|
||||
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']
|
||||
stats = {}
|
||||
for t in tables:
|
||||
try:
|
||||
cursor.execute(f"SELECT COUNT(*) FROM {t}")
|
||||
stats[t] = cursor.fetchone()[0]
|
||||
except Exception:
|
||||
stats[t] = 0
|
||||
conn.close()
|
||||
return {"status": "success", "tables_stats": stats}
|
||||
|
||||
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"
|
||||
params = []
|
||||
if date_str:
|
||||
query += " WHERE anomaly_date = ?"
|
||||
params.append(date_str)
|
||||
query += " ORDER BY id DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
cursor.execute(query, params)
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
return {"status": "success", "count": len(rows), "anomalies": [dict(r) for r in rows]}
|
||||
|
||||
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"
|
||||
params = []
|
||||
if category:
|
||||
query += " WHERE category = ?"
|
||||
params.append(category)
|
||||
query += " ORDER BY id ASC"
|
||||
cursor.execute(query, params)
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
return {"status": "success", "count": len(rows), "reference_items": [dict(r) for r in rows]}
|
||||
|
||||
def db_confirm_prompt_preview(session_id: str) -> Dict[str, Any]:
|
||||
session_state = db_get_session_state(session_id)
|
||||
if session_state and session_state.get("state_type") == "PROMPT_PREVIEW":
|
||||
pending_text = session_state.get("pending_data", "")
|
||||
res = db_add_system_prompt("main_agent", pending_text)
|
||||
db_clear_session_state(session_id) # Очищаем состояние
|
||||
return res
|
||||
return {"status": "error", "message": "Нет активного превью для подтверждения."}
|
||||
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
Модуль выгрузки и очистки срезов/снапшотов СКУД.
|
||||
"""
|
||||
import json
|
||||
from typing import Dict, Any, Optional
|
||||
from .connection import get_db_connection
|
||||
from .db_prompts import db_set_session_state
|
||||
|
||||
def smart_parse_date(date_str: Optional[str], original_user_message: str = "") -> Optional[str]:
|
||||
return date_str
|
||||
|
||||
def db_get_snapshots(session_id: str = "web_session_main", date_str: Optional[str] = None, original_user_message: str = "") -> Dict[str, Any]:
|
||||
date_str = smart_parse_date(date_str, original_user_message)
|
||||
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = """
|
||||
SELECT snapshot_id, log_date, snapshot_time, COUNT(*) as record_count
|
||||
FROM scud_logs
|
||||
"""
|
||||
params = []
|
||||
|
||||
if date_str:
|
||||
iso_date = date_str
|
||||
if "." in date_str:
|
||||
parts = date_str.split(".")
|
||||
if len(parts) == 3:
|
||||
iso_date = f"{parts[2]}-{parts[1]}-{parts[0]}"
|
||||
|
||||
query += " WHERE log_date = ? OR log_date = ? OR snapshot_time LIKE ? "
|
||||
params.extend([date_str, iso_date, f"{iso_date}%"])
|
||||
|
||||
query += " GROUP BY snapshot_id ORDER BY id DESC LIMIT 50"
|
||||
|
||||
cursor.execute(query, params)
|
||||
rows = cursor.fetchall()
|
||||
snapshots = [dict(r) for r in rows]
|
||||
|
||||
result_data = {
|
||||
"query_date": date_str or "все",
|
||||
"snapshots_count": len(snapshots),
|
||||
"snapshots": snapshots
|
||||
}
|
||||
|
||||
db_set_session_state(
|
||||
session_id=session_id,
|
||||
state_type="SNAPSHOTS_VIEW",
|
||||
data=json.dumps(result_data, ensure_ascii=False)
|
||||
)
|
||||
|
||||
conn.close()
|
||||
return result_data
|
||||
|
||||
def db_delete_snapshots(snapshot_id: Optional[str] = None, day_str: Optional[str] = None) -> Dict[str, Any]:
|
||||
if not snapshot_id and not day_str:
|
||||
return {"status": "error", "message": "Необходимо указать snapshot_id или day_str (ДД.ММ.ГГГГ)."}
|
||||
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
if snapshot_id:
|
||||
cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,))
|
||||
deleted = cursor.rowcount
|
||||
else:
|
||||
cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (day_str, f"%{day_str.replace('.', '')}%"))
|
||||
deleted = cursor.rowcount
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"status": "success", "message": f"Успешно удалено записей: {deleted}"}
|
||||
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
Модуль управления бэклогом задач (Task Tracker).
|
||||
"""
|
||||
import re
|
||||
from typing import List, Dict, Any, Optional
|
||||
from .connection import get_db_connection
|
||||
|
||||
def normalize_task_id(task_id_input: str) -> str:
|
||||
if not task_id_input:
|
||||
return ""
|
||||
clean_id = str(task_id_input).strip().upper().replace("TASK-", "").replace("TASK", "")
|
||||
if clean_id.isdigit():
|
||||
num = int(clean_id)
|
||||
return f"TASK-{(num):02d}" if num < 100 else f"TASK-{(num):03d}"
|
||||
return f"TASK-{clean_id}"
|
||||
|
||||
def db_get_tasks(user_id: int) -> List[Dict[str, Any]]:
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT id, task_id, module, title, priority, status, due_date, created_at
|
||||
FROM tasks
|
||||
WHERE user_id = ?
|
||||
ORDER BY id DESC
|
||||
""", (user_id,))
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def db_add_task(user_id: int, module: str, title: str, priority: str = "MEDIUM", due_date: Optional[str] = None) -> Dict[str, Any]:
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Считываем все существующие TASK-XX и находим реальный максимум
|
||||
cursor.execute("SELECT task_id FROM tasks")
|
||||
rows = cursor.fetchall()
|
||||
|
||||
max_num = 0
|
||||
for r in rows:
|
||||
t_id = r["task_id"] or ""
|
||||
match = re.search(r'\d+', t_id)
|
||||
if match:
|
||||
num = int(match.group(0))
|
||||
if num > max_num:
|
||||
max_num = num
|
||||
|
||||
new_task_id = f"TASK-{(max_num + 1):02d}"
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO tasks (task_id, module, title, priority, status, due_date, user_id)
|
||||
VALUES (?, ?, ?, ?, 'BACKLOG', ?, ?)
|
||||
""", (new_task_id, module, title, priority.upper(), due_date, user_id))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"status": "success", "task_id": new_task_id, "message": f"Задача {new_task_id} создана"}
|
||||
|
||||
def db_update_task_status(user_id: int, task_id: str, status: str = "COMPLETED", due_date: Optional[str] = None) -> Dict[str, Any]:
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
formatted_id = normalize_task_id(task_id)
|
||||
|
||||
if due_date:
|
||||
cursor.execute("""
|
||||
UPDATE tasks
|
||||
SET status = ?, due_date = ?
|
||||
WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
|
||||
""", (status.upper(), due_date, formatted_id, f"%{task_id.strip()}", user_id))
|
||||
else:
|
||||
cursor.execute("""
|
||||
UPDATE tasks
|
||||
SET status = ?
|
||||
WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
|
||||
""", (status.upper(), formatted_id, f"%{task_id.strip()}", user_id))
|
||||
|
||||
if cursor.rowcount == 0:
|
||||
conn.close()
|
||||
return {"error": f"Задача {task_id} не найдена или принадлежит другому пользователю"}
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"status": "success", "message": f"Статус задачи {formatted_id} обновлен на {status.upper()}"}
|
||||
|
||||
def db_delete_task(user_id: int, task_id: str) -> Dict[str, Any]:
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
formatted_id = normalize_task_id(task_id)
|
||||
|
||||
cursor.execute("""
|
||||
DELETE FROM tasks
|
||||
WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
|
||||
""", (formatted_id, f"%{task_id.strip()}", user_id))
|
||||
|
||||
if cursor.rowcount == 0:
|
||||
conn.close()
|
||||
return {"error": f"Задача {task_id} не найдена"}
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"status": "success", "message": f"Задача {formatted_id} удалена"}
|
||||
+36
-128
@@ -1,129 +1,37 @@
|
||||
import sqlite3
|
||||
from typing import List, Dict, Any
|
||||
"""
|
||||
Прослойка совместимости: реэкспортирует функции из llm/db/
|
||||
"""
|
||||
from .db.connection import DB_PATH, get_db_connection
|
||||
from .db.db_chat import db_save_chat_message, db_get_chat_history
|
||||
from .db.db_tasks import (
|
||||
normalize_task_id,
|
||||
db_get_tasks,
|
||||
db_add_task,
|
||||
db_update_task_status,
|
||||
db_delete_task
|
||||
)
|
||||
from .db.db_snapshots import db_get_snapshots, db_delete_snapshots
|
||||
from .db.db_prompts import (
|
||||
db_get_active_system_prompt,
|
||||
db_add_system_prompt,
|
||||
db_get_rules,
|
||||
db_set_session_state,
|
||||
db_get_session_state,
|
||||
db_clear_session_state,
|
||||
db_get_session_states,
|
||||
db_get_stats,
|
||||
db_get_anomalies,
|
||||
db_confirm_prompt_preview,
|
||||
db_get_reference
|
||||
)
|
||||
from datetime import datetime
|
||||
|
||||
DB_NAME = "/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db"
|
||||
|
||||
def db_get_active_system_prompt() -> str:
|
||||
try:
|
||||
conn = sqlite3.connect(DB_NAME)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT prompt_text FROM system_prompts WHERE name = 'main_agent' AND is_active = 1 LIMIT 1")
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
if row and row[0]:
|
||||
return row[0].replace("**", "")
|
||||
except Exception as e:
|
||||
print(f"Ошибка чтения системного промпта из БД: {e}")
|
||||
|
||||
return "Ты — интеллектуальный ИИ-ассистент и архитектурный координатор проекта SCUD Orion AI."
|
||||
|
||||
def db_add_system_prompt(name: str = "main_agent", prompt_text: str = "") -> dict:
|
||||
clean_text = prompt_text.replace("**", "")
|
||||
conn = sqlite3.connect(DB_NAME)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO system_prompts (name, prompt_text, is_active)
|
||||
VALUES (?, ?, 1)
|
||||
ON CONFLICT(name) DO UPDATE SET prompt_text=excluded.prompt_text, updated_at=CURRENT_TIMESTAMP
|
||||
""", (name, clean_text))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"status": "success", "message": f"Системный промпт '{name}' успешно обновлен в базе!"}
|
||||
|
||||
def db_get_tasks(status: str = None) -> list:
|
||||
conn = sqlite3.connect(DB_NAME)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
if status:
|
||||
cursor.execute("SELECT * FROM tasks WHERE status = ? ORDER BY task_id ASC", (status.upper(),))
|
||||
else:
|
||||
cursor.execute("SELECT * FROM tasks ORDER BY task_id ASC")
|
||||
rows = [dict(r) for r in cursor.fetchall()]
|
||||
conn.close()
|
||||
return rows
|
||||
|
||||
def db_update_task_status(task_id: str, status: str = None, due_date: str = None) -> dict:
|
||||
conn = sqlite3.connect(DB_NAME)
|
||||
cursor = conn.cursor()
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if status:
|
||||
status_upper = status.upper()
|
||||
updates.append("status = ?")
|
||||
params.append(status_upper)
|
||||
if status_upper == "COMPLETED":
|
||||
updates.append("completed_at = CURRENT_TIMESTAMP")
|
||||
else:
|
||||
updates.append("completed_at = NULL")
|
||||
|
||||
if due_date:
|
||||
updates.append("due_date = ?")
|
||||
params.append(due_date)
|
||||
|
||||
if not updates:
|
||||
conn.close()
|
||||
return {"error": "Не указаны параметры для обновления"}
|
||||
|
||||
params.append(task_id.upper())
|
||||
query = f"UPDATE tasks SET {', '.join(updates)} WHERE task_id = ?"
|
||||
cursor.execute(query, tuple(params))
|
||||
|
||||
if cursor.rowcount == 0:
|
||||
conn.close()
|
||||
return {"error": f"Задача {task_id} не найдена"}
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"status": "success", "message": f"Задача {task_id} обновлена"}
|
||||
|
||||
def db_delete_task(task_id: str) -> dict:
|
||||
conn = sqlite3.connect(DB_NAME)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM tasks WHERE task_id = ?", (task_id.upper(),))
|
||||
deleted_count = cursor.rowcount
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
if deleted_count == 0:
|
||||
return {"error": f"Задача {task_id} не найдена"}
|
||||
return {"status": "success", "message": f"Задача {task_id} удалена"}
|
||||
|
||||
def db_add_task(module: str = "general", title: str = "", priority: str = "MEDIUM", due_date: str = None, task_id: str = None) -> dict:
|
||||
conn = sqlite3.connect(DB_NAME)
|
||||
cursor = conn.cursor()
|
||||
|
||||
if not task_id:
|
||||
cursor.execute("SELECT task_id FROM tasks WHERE task_id LIKE 'TASK-%'")
|
||||
rows = cursor.fetchall()
|
||||
max_num = 0
|
||||
for r in rows:
|
||||
try:
|
||||
num = int(r[0].replace("TASK-", ""))
|
||||
if num > max_num:
|
||||
max_num = num
|
||||
except ValueError:
|
||||
pass
|
||||
task_id = f"TASK-{max_num + 1:02d}"
|
||||
|
||||
try:
|
||||
cursor.execute(
|
||||
"INSERT INTO tasks (task_id, module, title, status, priority, due_date) VALUES (?, ?, ?, 'BACKLOG', ?, ?)",
|
||||
(task_id.upper(), module, title, priority.upper(), due_date)
|
||||
)
|
||||
conn.commit()
|
||||
res = {"status": "success", "message": f"Задача {task_id} создана", "task_id": task_id}
|
||||
except sqlite3.IntegrityError:
|
||||
res = {"error": f"Задача с ID {task_id} уже существует"}
|
||||
finally:
|
||||
conn.close()
|
||||
return res
|
||||
|
||||
def db_get_rules() -> list:
|
||||
conn = sqlite3.connect(DB_NAME)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT id, rule_text, added_by FROM ai_knowledge_base ORDER BY id")
|
||||
rows = [dict(r) for r in cursor.fetchall()]
|
||||
conn.close()
|
||||
return rows
|
||||
def db_get_current_server_time():
|
||||
now = datetime.now()
|
||||
days_ru = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"]
|
||||
return {
|
||||
"current_date": now.strftime("%d.%m.%Y"),
|
||||
"current_time": now.strftime("%H:%M:%S"),
|
||||
"day_of_week": days_ru[now.weekday()],
|
||||
"iso_date": now.strftime("%Y-%m-%d")
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import base64
|
||||
import os
|
||||
import subprocess
|
||||
import logging
|
||||
import pandas as pd
|
||||
|
||||
logger = logging.getLogger("FILE_PARSER")
|
||||
|
||||
def extract_text_from_file(file_bytes: bytes, filename: str) -> dict:
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
temp_filepath = f"/tmp/upload_{os.getpid()}_{filename}"
|
||||
|
||||
with open(temp_filepath, "wb") as f:
|
||||
f.write(file_bytes)
|
||||
|
||||
try:
|
||||
# 1. ИЗОБРАЖЕНИЯ (.png, .jpg, .jpeg, .bmp, .webp) -> Кодируем в Base64 для Vision LLM
|
||||
if ext in ['.png', '.jpg', '.jpeg', '.bmp', '.webp']:
|
||||
b64_str = base64.b64encode(file_bytes).decode('utf-8')
|
||||
return {
|
||||
"text": f"[ПРИКРЕПЛЕНО ИЗОБРАЖЕНИЕ: {filename}]",
|
||||
"image_b64": b64_str
|
||||
}
|
||||
|
||||
# 2. PDF ДОКУМЕНТЫ (Конвертируем 1-ю страницу в картинку для Vision LLM)
|
||||
elif ext == '.pdf':
|
||||
cmd = ['pdftotext', temp_filepath, '-']
|
||||
res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
pdf_text = res.stdout.strip()
|
||||
|
||||
img_prefix = f"/tmp/pdf_preview_{os.getpid()}"
|
||||
subprocess.run(['pdftoppm', '-png', '-r', '200', '-f', '1', '-l', '1', temp_filepath, img_prefix], check=True)
|
||||
|
||||
page_png = f"{img_prefix}-1.png"
|
||||
b64_str = None
|
||||
if os.path.exists(page_png):
|
||||
with open(page_png, "rb") as pf:
|
||||
b64_str = base64.b64encode(pf.read()).decode('utf-8')
|
||||
os.remove(page_png)
|
||||
|
||||
context_text = f"[ПРИКРЕПЛЕН ДОКУМЕНТ PDF: {filename}]"
|
||||
if pdf_text:
|
||||
context_text += f"\n\n[ЭЛЕКТРОННЫЙ ТЕКСТОВЫЙ СЛОЙ PDF]:\n{pdf_text}"
|
||||
|
||||
return {
|
||||
"text": context_text,
|
||||
"image_b64": b64_str
|
||||
}
|
||||
|
||||
# 3. ЭЛЕКТРОННЫЕ ТАБЛИЦЫ (.xlsx, .xls, .csv)
|
||||
elif ext in ['.xlsx', '.xls', '.csv']:
|
||||
if ext == '.csv':
|
||||
df = pd.read_csv(temp_filepath)
|
||||
else:
|
||||
df = pd.read_excel(temp_filepath)
|
||||
|
||||
total_rows = len(df)
|
||||
df_preview = df.head(100)
|
||||
table_str = df_preview.to_string(index=False)
|
||||
note = f"\n(Показано первых 100 строк из {total_rows})" if total_rows > 100 else ""
|
||||
return {
|
||||
"text": f"[СОДЕРЖИМОЕ ТАБЛИЦЫ {filename}]:\n{table_str}{note}",
|
||||
"image_b64": None
|
||||
}
|
||||
|
||||
# 4. ТЕКСТОВЫЕ ФАЙЛЫ
|
||||
elif ext in ['.txt', '.log', '.json', '.xml', '.md']:
|
||||
with open(temp_filepath, 'r', encoding='utf-8', errors='replace') as tf:
|
||||
return {
|
||||
"text": tf.read().strip(),
|
||||
"image_b64": None
|
||||
}
|
||||
|
||||
else:
|
||||
return {
|
||||
"text": f"[ОШИБКА: Формат {ext} не поддерживается]",
|
||||
"image_b64": None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при анализе файла {filename}: {e}")
|
||||
return {
|
||||
"text": f"[ОШИБКА ОБРАБОТКИ ФАЙЛА: {str(e)}]",
|
||||
"image_b64": None
|
||||
}
|
||||
finally:
|
||||
if os.path.exists(temp_filepath):
|
||||
os.remove(temp_filepath)
|
||||
+143
-27
@@ -3,7 +3,7 @@ TOOLS_SCHEMA = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_get_tasks",
|
||||
"description": "ПОЛУЧИТЬ СПИСОК ЗАДАЧ ПРОЕКТА. Вызывай ТОЛЬКО когда пользователь просит показать задачи, бэклог или список дел.",
|
||||
"description": "ПОЛУЧИТЬ СПИСОК ЗАДАЧ ТЕКУЩЕГО ПОЛЬЗОВАТЕЛЯ. Вызывай СРАЗУ при запросе 'покажи мои задачи' или 'список задач'. ВАЖНОЕ ПРАВИЛО ВЫВОДА: Выводи задачи ЕДИНЫМ плоским списком (нумерованным или маркированным) по порядку ID. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО группировать задачи по статусам (В процессе, Бэклог, Завершены) или создавать подзаголовки, если оператор явно не попросил о группировке!",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -23,29 +23,6 @@ TOOLS_SCHEMA = [
|
||||
"parameters": {"type": "object", "properties": {}}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_get_system_prompt",
|
||||
"description": "ПОЛУЧИТЬ ТЕКУЩИЙ СИСТЕМНЫЙ ПРОМПТ ИИ (system_prompts). Вызывай когда пользователь просит показать системный промпт, инструкции ассистента или промпт из базы.",
|
||||
"parameters": {"type": "object", "properties": {}}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_add_system_prompt",
|
||||
"description": "Обновить или добавить системный промпт ИИ в базу данных. Вызывай при командах 'задай системный промпт', 'измени промпт', 'обнови системный промпт'.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt_text": {"type": "string", "description": "Полный новый текст системного промпта"},
|
||||
"name": {"type": "string", "description": "Имя промпта, по умолчанию main_agent"}
|
||||
},
|
||||
"required": ["prompt_text"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -66,7 +43,7 @@ TOOLS_SCHEMA = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_delete_task",
|
||||
"description": "Удалить задачу из реестра по её task_id (например, TASK-18).",
|
||||
"description": "УДАЛИТЬ ЗАДАЧУ ИЗ РЕЕСТРА ЗАДАЧ. Вызывай ЭТУ ФУНКЦИЮ ВСЕГДА при командах 'удали задачу TASK-XX', 'стереть задачу'. ЗАПРЕЩЕНО использовать эту функцию для работы с системным промптом!",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -76,11 +53,53 @@ TOOLS_SCHEMA = [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_get_snapshots",
|
||||
"description": "ПОЛУЧИТЬ АКТУАЛЬНЫЙ СПИСОК СНАПШОТОВ ИЗ БАЗЫ SQLITE. Вызывай ЭТУ ФУНКЦИЮ ВСЕГДА, даже если список снапшотов уже есть в истории чата или пользователь просит 'обновить', 'повторить запрос', 'проверить снова'. ЗАПРЕЩЕНО беречь контекст и выводить старые данные из истории!",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"date_str": {
|
||||
"type": "string",
|
||||
"description": "Точная дата в формате ДД.ММ.ГГГГ (например, '12.08.2026'), взятая из [СИСТЕМНОГО КАЛЕНДАРЯ СЕРВЕРА]."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"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_get_current_server_time",
|
||||
"description": "ПОЛУЧИТЬ ТЕКУЩУЮ ДАТУ, ВРЕМЯ И ДЕНЬ НЕДЕЛИ СЕРВЕРА. Вызывай МГНОВЕННО при любых вопросах пользователя про точное текущее время или текущую дату.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_add_task",
|
||||
"description": "Добавить новую задачу в бэклог проекта.",
|
||||
"description": "ДОБАВИТЬ НОВУЮ ЗАДАЧУ. Вызывай ЭТУ ФУНКЦИЮ ВСЕГДА, когда пользователь просит 'добавь новую задачу', 'создай задачу', 'поставь задачу'. ЗАПРЕЩЕНО писать о добавлении задачи текстом без вызова этой функции!",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -92,5 +111,102 @@ TOOLS_SCHEMA = [
|
||||
"required": ["title"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_get_stats",
|
||||
"description": "ПОЛУЧИТЬ ОБЩУЮ СТАТИСТИКУ БАЗЫ ДАННЫХ. Вызывай, когда пользователь просит показать общую статистику БД, количество записей в таблицах или размер базы.",
|
||||
"parameters": {"type": "object", "properties": {}}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_get_anomalies",
|
||||
"description": "ПОЛУЧИТЬ ИСТОРИЮ АНОМАЛИЙ СКУД ⟷ 1С. Вызывай при запросах на просмотр аномалий или расхождений. Передавай date_str если пользователь просит аномалии за конкретный день, или увеличенный limit (например 100) если просит все.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {"type": "integer", "description": "Максимальное количество записей (по умолчанию 100)"},
|
||||
"date_str": {"type": "string", "description": "Опциональная дата в формате ДД.ММ.ГГГГ"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_get_session_states",
|
||||
"description": "ПОЛУЧИТЬ АКТИВНЫЕ СЕССИИ И ПРЕВЬЮ (session_states). Вызывай, когда пользователь просит показать текущие сессии или статус превью.",
|
||||
"parameters": {"type": "object", "properties": {}}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_get_reference",
|
||||
"description": "ПОЛУЧИТЬ СИСТЕМНЫЙ СПРАВОЧНИК И ПРИМЕРЫ КОМАНД ДЛЯ ОПЕРАТОРА (system_reference). Вызывай ВСЕГДА, когда пользователь спрашивает про возможности ассистента, список команд, примерах промптов или справе по работе с системой.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": "string",
|
||||
"description": "Фильтр категории: scud, tasks, calendar или system. Если просят всё — не передавай параметр."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_preview_prompt_merge",
|
||||
"description": "СОЗДАТЬ ПРЕВЬЮ ИЗМЕНЕНИЙ СИСТЕМНОГО ПРОМПТА. Вызывай ЭТУ ФУНКЦИЮ ВСЕГДА при запросах на добавление, изменение или удаление пунктов системного промпта.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt_text": {
|
||||
"type": "string",
|
||||
"description": "Текст нового пункта для добавления ИЛИ инструкция по удалению/изменению (например: '3.4. Тестовый промпт' или 'удалить пункт 3.4')."
|
||||
}
|
||||
},
|
||||
"required": ["prompt_text"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_get_system_prompt",
|
||||
"description": (
|
||||
"СИНХРОНИЗИРОВАТЬ И ВЫЧИТАТЬ СИСТЕМНЫЙ ПРОМПТ ИЗ БАЗЫ SQLITE. "
|
||||
"Ты ОБЯЗАН вызывать ЭТУ ФУНКЦИЮ ВСЕГДА, когда пользователь хочет прочитать, обновить или синхронизировать промпт. "
|
||||
"ПРИМЕРЫ ВХОДЯЩИХ ЗАПРОСОВ ДЛЯ ВЫЗОВА ЭТОЙ ФУНКЦИИ:\n"
|
||||
"- 'покажи системный промпт' -> db_get_system_prompt()\n"
|
||||
"- 'обнови промпт из базы' -> db_get_system_prompt()\n"
|
||||
"- 'обнови из базы системный промпт' -> db_get_system_prompt()\n"
|
||||
"- 'загрузи промпт из базы' -> db_get_system_prompt()\n"
|
||||
"- 'покажи текст системного промпта' -> db_get_system_prompt()\n"
|
||||
"ЗАПРЕЩЕНО генерировать ответ текстом без предварительного вызова этой функции!"
|
||||
),
|
||||
"parameters": {"type": "object", "properties": {}}
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": {}}
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
@@ -1,166 +1,293 @@
|
||||
# === ANCHOR: IMPORTS_START ===
|
||||
import os
|
||||
import json
|
||||
import sqlite3
|
||||
from typing import List, Optional, Dict, Any
|
||||
import logging
|
||||
import urllib.request
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Depends, Security
|
||||
import jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import FastAPI, Depends, HTTPException, status, UploadFile, File, Form
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from llm.agent import process_chat_message
|
||||
# === ANCHOR: IMPORTS_END ===
|
||||
from llm.db_tools import db_get_tasks, DB_PATH
|
||||
from llm.file_parser import extract_text_from_file
|
||||
|
||||
|
||||
# === ANCHOR: APP_INIT_START ===
|
||||
app = FastAPI(
|
||||
title="SCUD Orion AI Context & Task Tracker API",
|
||||
version="2.0.0",
|
||||
description="REST API локального контекста, памяти и трекинга задач для ИИ-аудитора"
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=[logging.StreamHandler()]
|
||||
)
|
||||
|
||||
DB_NAME = "/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db"
|
||||
SECURITY_TOKEN = os.getenv("API_BEARER_TOKEN", "scud_secret_token_2026")
|
||||
JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure"
|
||||
ALGORITHM = "HS256"
|
||||
OLLAMA_URL = "http://192.168.11.3:11434/api/chat"
|
||||
MODEL_NAME = "qwen2.5:14b"
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
security = HTTPBearer()
|
||||
# === ANCHOR: APP_INIT_END ===
|
||||
|
||||
|
||||
# === ANCHOR: DATABASE_HELPERS_START ===
|
||||
def init_chat_table():
|
||||
"""Создает таблицу истории чата, если она не существует."""
|
||||
conn = sqlite3.connect(DB_NAME)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS chat_sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
history_json TEXT NOT NULL,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
init_chat_table()
|
||||
|
||||
|
||||
def verify_token(credentials: HTTPAuthorizationCredentials = Security(security)):
|
||||
if credentials.credentials != SECURITY_TOKEN:
|
||||
raise HTTPException(status_code=403, detail="Недействительный токен доступа")
|
||||
return credentials.credentials
|
||||
|
||||
|
||||
def get_db_connection():
|
||||
conn = sqlite3.connect(DB_NAME)
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
# === ANCHOR: DATABASE_HELPERS_END ===
|
||||
|
||||
def create_access_token(user_id: int, username: str, is_admin: bool) -> str:
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"username": username,
|
||||
"is_admin": is_admin,
|
||||
"exp": datetime.utcnow() + timedelta(days=30)
|
||||
}
|
||||
return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM)
|
||||
|
||||
# === ANCHOR: SCHEMAS_START ===
|
||||
class TaskSchema(BaseModel):
|
||||
task_id: str
|
||||
module: str
|
||||
title: str
|
||||
status: str
|
||||
priority: str
|
||||
completed_at: Optional[str] = None
|
||||
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]:
|
||||
try:
|
||||
token = credentials.credentials
|
||||
payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM])
|
||||
user_id = int(payload.get("sub"))
|
||||
username = payload.get("username")
|
||||
is_admin = bool(payload.get("is_admin", False))
|
||||
return {"id": user_id, "username": username, "is_admin": is_admin}
|
||||
except Exception as e:
|
||||
logging.warning(f"Auth error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Недействительный или просроченный токен авторизации",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
app = FastAPI(title="SCUD Orion AI Context API")
|
||||
|
||||
class RuleSchema(BaseModel):
|
||||
id: int
|
||||
category: str
|
||||
rule_text: str
|
||||
is_active: int
|
||||
if os.path.exists("static"):
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request, exc):
|
||||
logging.error(f"❌ ОШИБКА ВАЛИДАЦИИ 422 НА {request.url}: {exc.errors()}")
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={"detail": exc.errors(), "body": str(exc)}
|
||||
)
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
session_id: str = "default"
|
||||
message: str
|
||||
class AuthRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
full_name: Optional[str] = None
|
||||
is_admin: Optional[bool] = False
|
||||
|
||||
class ChatResponse(BaseModel):
|
||||
session_id: str
|
||||
reply: str
|
||||
# === ANCHOR: SCHEMAS_END ===
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
old_password: str
|
||||
new_password: str
|
||||
|
||||
# === API МАРШРУТЫ ===
|
||||
|
||||
# === ANCHOR: STATIC_ROUTES_START ===
|
||||
# Подключаем папку static для отдачи HTML-интерфейса
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
return FileResponse("static/index.html")
|
||||
# === ANCHOR: STATIC_ROUTES_END ===
|
||||
|
||||
@app.get("/favicon.ico")
|
||||
async def favicon():
|
||||
file_path = os.path.join("static", "favicon.ico")
|
||||
if os.path.exists(file_path):
|
||||
return FileResponse(file_path)
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
|
||||
# === ANCHOR: TASKS_ENDPOINTS_START ===
|
||||
@app.get("/api/v1/tasks", response_model=List[TaskSchema], dependencies=[Depends(verify_token)])
|
||||
def get_all_tasks(status: Optional[str] = None):
|
||||
conn = get_db_connection()
|
||||
@app.post("/api/v1/auth/login")
|
||||
def login(req: AuthRequest):
|
||||
username = req.username.strip().lower()
|
||||
logging.info(f"===> Попытка входа для пользователя: {username}")
|
||||
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
if status:
|
||||
cursor.execute("SELECT * FROM tasks WHERE status = ? ORDER BY task_id", (status.upper(),))
|
||||
else:
|
||||
cursor.execute("SELECT * FROM tasks ORDER BY task_id")
|
||||
rows = cursor.fetchall()
|
||||
cursor.execute("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", (username,))
|
||||
user = cursor.fetchone()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
if not user:
|
||||
logging.warning(f"===> Ошибка: Пользователь {username} не найден")
|
||||
raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль")
|
||||
|
||||
@app.post("/api/v1/tasks/{task_id}/complete", dependencies=[Depends(verify_token)])
|
||||
def mark_task_completed(task_id: str):
|
||||
conn = get_db_connection()
|
||||
if not pwd_context.verify(req.password, user["password_hash"]):
|
||||
logging.warning(f"===> Ошибка: Неверный пароль для {username}")
|
||||
raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль")
|
||||
|
||||
is_admin = bool(user["is_admin"]) or (user["username"] == "puh")
|
||||
token = create_access_token(user["id"], user["username"], is_admin)
|
||||
logging.info(f"===> УСПЕХ: Авторизован пользователь {username}")
|
||||
|
||||
return {"status": "success", "token": token, "username": user["username"], "is_admin": is_admin}
|
||||
|
||||
@app.post("/api/v1/auth/change-password")
|
||||
def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = Depends(get_current_user)):
|
||||
if not req.new_password or len(req.new_password) < 4:
|
||||
raise HTTPException(status_code=400, detail="Новый пароль должен содержать минимум 4 символа")
|
||||
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"UPDATE tasks SET status = 'COMPLETED', completed_at = CURRENT_TIMESTAMP WHERE task_id = ?",
|
||||
(task_id.upper(),)
|
||||
)
|
||||
if cursor.rowcount == 0:
|
||||
cursor.execute("SELECT password_hash FROM users WHERE id = ?", (current_user["id"],))
|
||||
user = cursor.fetchone()
|
||||
|
||||
if not user or not pwd_context.verify(req.old_password, user["password_hash"]):
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail=f"Задача {task_id} не найдена")
|
||||
raise HTTPException(status_code=400, detail="Неверный старый пароль")
|
||||
|
||||
new_hash = pwd_context.hash(req.new_password)
|
||||
cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, current_user["id"]))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"status": "success", "message": f"Задача {task_id} успешно выполнена"}
|
||||
# === ANCHOR: TASKS_ENDPOINTS_END ===
|
||||
|
||||
logging.info(f"Пароль изменен для пользователя ID: {current_user['id']}")
|
||||
return {"status": "success", "message": "Пароль успешно изменен"}
|
||||
|
||||
# === ANCHOR: RULES_AND_CHAT_ENDPOINTS_START ===
|
||||
@app.get("/api/v1/rules", response_model=List[RuleSchema], dependencies=[Depends(verify_token)])
|
||||
def get_active_rules():
|
||||
conn = get_db_connection()
|
||||
@app.get("/api/v1/admin/users")
|
||||
def list_users(current_user: Dict[str, Any] = Depends(get_current_user)):
|
||||
if not current_user["is_admin"]:
|
||||
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
|
||||
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM architecture_memory WHERE is_active = 1 ORDER BY id")
|
||||
rows = cursor.fetchall()
|
||||
cursor.execute("SELECT id, username, full_name, is_admin, created_at FROM users ORDER BY id ASC")
|
||||
users = [dict(r) for r in cursor.fetchall()]
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
return users
|
||||
|
||||
@app.post("/api/v1/admin/users")
|
||||
def create_user(req: CreateUserRequest, current_user: Dict[str, Any] = Depends(get_current_user)):
|
||||
if not current_user["is_admin"]:
|
||||
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
|
||||
|
||||
@app.post("/api/v1/chat", response_model=ChatResponse, dependencies=[Depends(verify_token)])
|
||||
def chat_endpoint(req: ChatRequest):
|
||||
"""Принимает сообщение, подтягивает историю из БД, выполняет вызовы функций к БД и сохраняет историю."""
|
||||
conn = get_db_connection()
|
||||
username = req.username.strip().lower()
|
||||
if not username or not req.password:
|
||||
raise HTTPException(status_code=400, detail="Заполните имя пользователя и пароль")
|
||||
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT history_json FROM chat_sessions WHERE session_id = ?", (req.session_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
chat_history = json.loads(row["history_json"]) if row else []
|
||||
|
||||
reply, updated_history = process_chat_message(req.message, chat_history)
|
||||
|
||||
cursor.execute("SELECT id FROM users WHERE username = ?", (username,))
|
||||
if cursor.fetchone():
|
||||
conn.close()
|
||||
raise HTTPException(status_code=400, detail="Пользователь с таким именем уже существует")
|
||||
|
||||
pwd_hash = pwd_context.hash(req.password)
|
||||
full_name = req.full_name.strip() if req.full_name else None
|
||||
is_admin = 1 if req.is_admin else 0
|
||||
|
||||
cursor.execute(
|
||||
"INSERT OR REPLACE INTO chat_sessions (session_id, history_json, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
|
||||
(req.session_id, json.dumps(updated_history, ensure_ascii=False))
|
||||
"INSERT INTO users (username, password_hash, full_name, is_admin) VALUES (?, ?, ?, ?)",
|
||||
(username, pwd_hash, full_name, is_admin)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
logging.info(f"Создан пользователь: {username} (admin={is_admin}) админом {current_user['username']}")
|
||||
return {"status": "success", "message": f"Пользователь {username} создан"}
|
||||
|
||||
@app.delete("/api/v1/admin/users/{user_id}")
|
||||
def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current_user)):
|
||||
if not current_user["is_admin"]:
|
||||
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
|
||||
|
||||
if user_id == current_user["id"]:
|
||||
raise HTTPException(status_code=400, detail="Нельзя удалить самого себя")
|
||||
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
logging.info(f"Удален пользователь ID: {user_id}")
|
||||
return {"status": "success", "message": "Пользователь удален"}
|
||||
|
||||
@app.get("/api/v1/tasks")
|
||||
def get_tasks(user: Dict[str, Any] = Depends(get_current_user)):
|
||||
return db_get_tasks(user_id=user["id"])
|
||||
|
||||
# ЧАТ С ПОДДЕРЖКОЙ ФАЙЛОВ И АВТОРИЗАЦИИ
|
||||
|
||||
from llm.db_tools import db_get_session_state
|
||||
|
||||
@app.post("/api/v1/chat")
|
||||
async def chat_endpoint(
|
||||
session_id: str = Form("web_session_main"),
|
||||
message: str = Form(""),
|
||||
file: Optional[UploadFile] = File(default=None),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
parsed_file = {"text": "", "image_b64": None}
|
||||
if file and file.filename:
|
||||
file_bytes = await file.read()
|
||||
parsed_file = extract_text_from_file(file_bytes, file.filename)
|
||||
|
||||
# 1. Сначала обрабатываем сообщение и вызовы инструментов
|
||||
reply, history = process_chat_message(
|
||||
user_id=current_user["id"],
|
||||
user_message=message,
|
||||
file_context=parsed_file["text"],
|
||||
image_b64=parsed_file["image_b64"],
|
||||
session_id=session_id
|
||||
)
|
||||
|
||||
return ChatResponse(session_id=req.session_id, reply=reply)
|
||||
# 2. СТРОГО ПОСЛЕ обработки проверяем, осталось ли активное превью в базе
|
||||
from llm.db_tools import db_get_session_state
|
||||
state = db_get_session_state(session_id)
|
||||
needs_confirm = bool(state and state.get("state_type") in ["PROMPT_PREVIEW", "TASK_DELETE_PREVIEW"])
|
||||
|
||||
return {"reply": reply, "history": history, "needs_confirmation": needs_confirm}
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
return {"status": "healthy", "service": "scud_context_api", "version": "2.0.0"}
|
||||
# === ANCHOR: RULES_AND_CHAT_ENDPOINTS_END ===
|
||||
@app.post("/api/v1/chat/guest")
|
||||
async def guest_chat_endpoint(
|
||||
session_id: str = Form("web_session_main"),
|
||||
message: str = Form(""),
|
||||
file: Optional[UploadFile] = File(default=None)
|
||||
):
|
||||
parsed_file = {"text": "", "image_b64": None}
|
||||
if file and file.filename:
|
||||
file_bytes = await file.read()
|
||||
parsed_file = extract_text_from_file(file_bytes, file.filename)
|
||||
|
||||
reply, history = process_chat_message(
|
||||
user_id=0,
|
||||
user_message=message,
|
||||
file_context=parsed_file["text"],
|
||||
image_b64=parsed_file["image_b64"],
|
||||
session_id=session_id
|
||||
)
|
||||
|
||||
state = db_get_session_state(session_id)
|
||||
needs_confirm = bool(state and state.get("state_type") == "PROMPT_PREVIEW")
|
||||
|
||||
return {"reply": reply, "history": history, "needs_confirmation": needs_confirm}
|
||||
|
||||
# === СТРОГО В КОНЦЕ: ФОЛЛБЭК СТАТИКИ ===
|
||||
|
||||
@app.get("/{file_path:path}")
|
||||
def serve_static_fallback(file_path: str):
|
||||
clean_path = file_path.lstrip("/")
|
||||
|
||||
target = os.path.join("static", clean_path)
|
||||
if os.path.isfile(target):
|
||||
return FileResponse(target)
|
||||
|
||||
filename = os.path.basename(clean_path)
|
||||
target_js = os.path.join("static/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/css", filename)
|
||||
if filename.endswith(".css") and os.path.isfile(target_css):
|
||||
return FileResponse(target_css, media_type="text/css")
|
||||
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
SNI is required.
|
||||
@@ -0,0 +1 @@
|
||||
SNI is required.
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Скрипт полной очистки истории диалогов и сессионных состояний SQLite.
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
DB_PATH = "/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db"
|
||||
|
||||
def clear_chat_history():
|
||||
if not os.path.exists(DB_PATH):
|
||||
print(f"❌ База данных не найдена по адресу: {DB_PATH}")
|
||||
return
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("DELETE FROM chat_messages;")
|
||||
cursor.execute("DELETE FROM session_states;")
|
||||
cursor.execute("DELETE FROM chat_sessions;")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print("✓ [SUCCESS] История сообщений чата и сессионные состояния успешно очищены!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
clear_chat_history()
|
||||
@@ -0,0 +1,47 @@
|
||||
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)
|
||||
@@ -0,0 +1,23 @@
|
||||
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)
|
||||
@@ -0,0 +1,31 @@
|
||||
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)")
|
||||
@@ -0,0 +1,21 @@
|
||||
import os
|
||||
|
||||
EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'}
|
||||
|
||||
def print_tree(startpath):
|
||||
print("=" * 60)
|
||||
print("📂 ДЕРЕВО АРХИТЕКТУРЫ ПРОЕКТА")
|
||||
print("=" * 60)
|
||||
for root, dirs, files in os.walk(startpath):
|
||||
dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS]
|
||||
level = root.replace(startpath, '').count(os.sep)
|
||||
indent = ' ' * 4 * (level)
|
||||
print(f'{indent}📁 {os.path.basename(root)}/')
|
||||
subindent = ' ' * 4 * (level + 1)
|
||||
for f in sorted(files):
|
||||
if not f.endswith('.pyc'):
|
||||
print(f'{subindent}📄 {f}')
|
||||
print("=" * 60)
|
||||
|
||||
if __name__ == "__main__":
|
||||
print_tree('.')
|
||||
@@ -0,0 +1,56 @@
|
||||
/* Плавное исчезновение текста сверху при скролле */
|
||||
.fade-scroll-top {
|
||||
mask-image: linear-gradient(to bottom, transparent 0%, black 14px);
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 14px);
|
||||
}
|
||||
|
||||
/* Скрытие стандартного скроллбара */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* Оптимизация под мобильный viewport (борьба со скачками клавиатуры на iOS/Android) */
|
||||
body {
|
||||
min-height: 100vh;
|
||||
min-height: -webkit-fill-available;
|
||||
}
|
||||
/* Плавное исчезновение текста сверху при скролле */
|
||||
.fade-scroll-top {
|
||||
mask-image: linear-gradient(to bottom, transparent 0%, black 14px);
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 14px);
|
||||
}
|
||||
|
||||
/* Скрытие стандартного скроллбара */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* Оптимизация под мобильный viewport */
|
||||
body {
|
||||
min-height: 100vh;
|
||||
min-height: -webkit-fill-available;
|
||||
}
|
||||
|
||||
/* Анимация появления плашки с кнопками подтверждения */
|
||||
.confirm-action-bar {
|
||||
animation: slideUp 0.25s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
@app.get("/favicon.ico")
|
||||
async def favicon():
|
||||
file_path = os.path.join("static", "favicon.ico")
|
||||
if os.path.exists(file_path):
|
||||
return FileResponse(file_path)
|
||||
raise HTTPException(status_code=404)
|
||||
+211
-282
@@ -2,331 +2,260 @@
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>SCUD Orion AI — Context Manager</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
/* Плавное (градиентное) исчезновение текста сверху при скролле */
|
||||
.fade-scroll-top {
|
||||
mask-image: linear-gradient(to bottom, transparent 0%, black 14px);
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 14px);
|
||||
}
|
||||
/* Скрытие стандартного скроллбара */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/css/styles.css">
|
||||
</head>
|
||||
<body class="bg-slate-50 text-slate-800 h-screen flex flex-col font-sans">
|
||||
<body class="bg-slate-100 text-slate-800 h-[100dvh] w-full flex flex-col font-sans overflow-hidden">
|
||||
|
||||
<!-- Окно авторизации -->
|
||||
<div id="auth-modal" class="fixed inset-0 bg-slate-900/80 backdrop-blur-md z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-2xl p-6 sm:p-8 max-w-md w-full shadow-2xl border border-slate-200">
|
||||
<div class="flex items-center space-x-3 mb-6">
|
||||
<div class="bg-indigo-600 text-white p-3 rounded-xl">
|
||||
<i class="fa-solid fa-user-shield text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-900">SCUD Orion AI</h2>
|
||||
<p class="text-xs text-slate-500">Авторизация в системе</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1">Имя пользователя</label>
|
||||
<input type="text" id="auth-username-input" placeholder="Введите логин..." required autocomplete="username"
|
||||
class="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 focus:outline-none focus:border-indigo-600 focus:bg-white transition">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1">Пароль</label>
|
||||
<input type="password" id="auth-password-input" placeholder="Введите пароль..." required autocomplete="current-password"
|
||||
class="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 focus:outline-none focus:border-indigo-600 focus:bg-white transition">
|
||||
</div>
|
||||
|
||||
<div id="auth-error" class="hidden text-xs text-red-600 font-medium bg-red-50 p-3 rounded-xl border border-red-200"></div>
|
||||
|
||||
<button type="button" onclick="handleLogin()" id="auth-btn" class="w-full bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white font-semibold py-3 rounded-xl text-sm transition shadow-md flex items-center justify-center gap-2">
|
||||
<i class="fa-solid fa-right-to-bracket"></i>
|
||||
<span>Войти в систему</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="relative my-4">
|
||||
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-slate-200"></div></div>
|
||||
<div class="relative flex justify-center text-xs uppercase"><span class="bg-white px-2 text-slate-400 font-medium">Или</span></div>
|
||||
</div>
|
||||
|
||||
<button type="button" onclick="enableGuestMode()" class="w-full bg-slate-100 hover:bg-slate-200 text-slate-700 font-semibold py-2.5 rounded-xl text-xs transition border border-slate-300 flex items-center justify-center gap-2">
|
||||
<i class="fa-solid fa-user-ninja"></i>
|
||||
<span>Войти как гость (Локальный ИИ)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно смены пароля -->
|
||||
<div id="change-pwd-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-2xl p-6 max-w-sm w-full shadow-2xl border border-slate-200">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="font-bold text-slate-800 text-sm flex items-center gap-2">
|
||||
<i class="fa-solid fa-key text-indigo-600"></i> Смена пароля
|
||||
</h3>
|
||||
<button type="button" onclick="closeChangePasswordModal()" class="text-slate-400 hover:text-slate-700">
|
||||
<i class="fa-solid fa-xmark text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-[11px] font-semibold text-slate-600 uppercase mb-1">Старый пароль</label>
|
||||
<input type="password" id="old-pwd-input" required class="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2 text-xs text-slate-900 focus:outline-none focus:border-indigo-600">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[11px] font-semibold text-slate-600 uppercase mb-1">Новый пароль</label>
|
||||
<input type="password" id="new-pwd-input" required class="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2 text-xs text-slate-900 focus:outline-none focus:border-indigo-600">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[11px] font-semibold text-slate-600 uppercase mb-1">Повторите новый пароль</label>
|
||||
<input type="password" id="confirm-pwd-input" required class="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2 text-xs text-slate-900 focus:outline-none focus:border-indigo-600">
|
||||
</div>
|
||||
|
||||
<div id="pwd-error" class="hidden text-xs text-red-600 bg-red-50 p-2 rounded-lg border border-red-200"></div>
|
||||
<div id="pwd-success" class="hidden text-xs text-emerald-600 bg-emerald-50 p-2 rounded-lg border border-emerald-200"></div>
|
||||
|
||||
<button type="button" onclick="handleChangePassword()" class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2.5 rounded-xl text-xs transition shadow-sm mt-2">
|
||||
Сохранить новый пароль
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно управления пользователями -->
|
||||
<div id="admin-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-2xl p-6 max-w-lg w-full shadow-2xl border border-slate-200 flex flex-col max-h-[85vh]">
|
||||
<div class="flex justify-between items-center mb-4 pb-2 border-b border-slate-200">
|
||||
<h3 class="font-bold text-slate-800 text-sm flex items-center gap-2">
|
||||
<i class="fa-solid fa-users-gear text-indigo-600"></i> Управление пользователями
|
||||
</h3>
|
||||
<button type="button" onclick="closeAdminModal()" class="text-slate-400 hover:text-slate-700">
|
||||
<i class="fa-solid fa-xmark text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 mb-4 bg-slate-50 p-3.5 rounded-xl border border-slate-200 shrink-0">
|
||||
<p class="text-[11px] font-bold text-slate-700 uppercase">Создать нового пользователя</p>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<input type="text" id="new-user-name" placeholder="Логин *" required class="bg-white border border-slate-300 rounded-lg px-2.5 py-1.5 text-xs">
|
||||
<input type="password" id="new-user-pwd" placeholder="Пароль *" required class="bg-white border border-slate-300 rounded-lg px-2.5 py-1.5 text-xs">
|
||||
</div>
|
||||
<input type="text" id="new-user-fullname" placeholder="ФИО (необязательно)" class="w-full bg-white border border-slate-300 rounded-lg px-2.5 py-1.5 text-xs">
|
||||
<div class="flex items-center justify-between pt-1">
|
||||
<label class="flex items-center gap-2 text-xs text-slate-700 cursor-pointer">
|
||||
<input type="checkbox" id="new-user-is-admin" class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
||||
<span>Права администратора</span>
|
||||
</label>
|
||||
<button type="button" onclick="handleCreateUser()" class="bg-indigo-600 hover:bg-indigo-700 text-white font-semibold px-4 py-1.5 rounded-lg text-xs transition">
|
||||
+ Добавить
|
||||
</button>
|
||||
</div>
|
||||
<div id="admin-msg" class="hidden text-[11px] text-red-600 pt-1"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto space-y-2 pr-1" id="admin-users-list">
|
||||
<div class="text-xs text-slate-400 py-4 text-center">Загрузка пользователей...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Хедер -->
|
||||
<header class="bg-white border-b border-slate-200 px-6 py-3 flex justify-between items-center shadow-sm">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="bg-indigo-600 text-white p-2 rounded-lg">
|
||||
<header class="bg-white border-b border-slate-200 px-4 py-2.5 flex justify-between items-center shadow-sm shrink-0 z-20">
|
||||
<div class="flex items-center space-x-2.5">
|
||||
<div class="bg-indigo-600 text-white p-2 rounded-xl shrink-0">
|
||||
<i class="fa-solid fa-brain text-lg"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-base font-bold leading-none text-slate-900">SCUD Orion AI</h1>
|
||||
<span class="text-xs text-slate-500">Context & Task Manager</span>
|
||||
<h1 class="text-sm font-bold text-slate-900 leading-tight">SCUD Orion AI</h1>
|
||||
<span class="text-[11px] text-slate-500">Task & Context API</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="flex items-center text-xs text-emerald-600 font-medium bg-emerald-50 px-2.5 py-1 rounded-full border border-emerald-200">
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-500 mr-1.5"></span> API Online
|
||||
<div class="flex items-center space-x-1.5">
|
||||
<span id="guest-badge" class="hidden text-[10px] text-amber-700 font-semibold bg-amber-50 px-2 py-0.5 rounded-full border border-amber-200">
|
||||
Гость
|
||||
</span>
|
||||
<button onclick="toggleDrawer()" class="bg-indigo-50 hover:bg-indigo-100 text-indigo-700 px-3.5 py-1.5 rounded-lg text-xs font-semibold flex items-center gap-2 transition border border-indigo-200">
|
||||
<i class="fa-solid fa-list-check text-indigo-600"></i>
|
||||
<span>Реестр задач</span>
|
||||
<span id="task-count-badge" class="bg-indigo-600 text-white text-[10px] px-1.5 py-0.2 rounded-full">0</span>
|
||||
|
||||
<span id="username-badge" class="hidden text-xs text-indigo-700 font-bold bg-indigo-50 px-2.5 py-1 rounded-full border border-indigo-200">
|
||||
puh
|
||||
</span>
|
||||
|
||||
<button id="admin-users-btn" type="button" onclick="openAdminModal()" class="hidden text-slate-500 hover:text-indigo-600 transition p-2 rounded-xl" title="Управление пользователями">
|
||||
<i class="fa-solid fa-users-gear text-base"></i>
|
||||
</button>
|
||||
|
||||
<button id="change-pwd-btn" type="button" onclick="openChangePasswordModal()" class="hidden text-slate-500 hover:text-indigo-600 transition p-2 rounded-xl" title="Сменить пароль">
|
||||
<i class="fa-solid fa-key text-base"></i>
|
||||
</button>
|
||||
|
||||
<button id="tasks-drawer-btn" type="button" onclick="toggleDrawer()" class="bg-indigo-600 active:bg-indigo-700 text-white px-3 py-1.5 rounded-xl text-xs font-semibold flex items-center gap-1.5 shadow-sm">
|
||||
<i class="fa-solid fa-list-check"></i>
|
||||
<span>Задачи</span>
|
||||
<span id="task-count-badge" class="bg-white text-indigo-700 text-[10px] font-bold px-1.5 py-0.2 rounded-full">0</span>
|
||||
</button>
|
||||
|
||||
<button type="button" onclick="logout()" class="text-slate-400 hover:text-red-600 transition p-2 rounded-xl" title="Выйти">
|
||||
<i class="fa-solid fa-right-from-bracket text-base"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Основная зона чата -->
|
||||
<main class="flex-1 flex flex-col max-w-4xl w-full mx-auto bg-white my-4 rounded-xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div id="chat-window" class="flex-1 p-6 overflow-y-auto space-y-4 bg-slate-50/50">
|
||||
<div class="bg-white border border-slate-200 rounded-xl p-4 max-w-2xl shadow-sm">
|
||||
<p class="text-xs font-bold text-indigo-600 uppercase tracking-wider mb-1">
|
||||
<!-- Главный контейнер -->
|
||||
<div class="flex-1 flex flex-col min-h-0 w-full max-w-4xl mx-auto bg-white relative overflow-hidden">
|
||||
<div id="chat-window" class="flex-1 p-3.5 overflow-y-auto space-y-3 bg-slate-50/50">
|
||||
<div id="drop-overlay" class="absolute inset-0 bg-indigo-600/10 backdrop-blur-sm border-2 border-dashed border-indigo-600 rounded-2xl hidden flex-col items-center justify-center z-30 transition-all pointer-events-none">
|
||||
<div class="bg-white p-4 rounded-2xl shadow-xl flex flex-col items-center gap-2">
|
||||
<i class="fa-solid fa-cloud-arrow-up text-3xl text-indigo-600 animate-bounce"></i>
|
||||
<p class="text-sm font-bold text-slate-800">Перетащите файл сюда</p>
|
||||
<p class="text-xs text-slate-500">Поддерживаются PDF, изображения, таблицы, TXT</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm">
|
||||
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
|
||||
<i class="fa-solid fa-robot mr-1"></i> ИИ-Ассистент
|
||||
</p>
|
||||
<p class="text-slate-700 text-sm leading-relaxed">
|
||||
Привет! Я подключен к вашей базе задач и системным промптам. Вы можете писать команды прямо в чат или управлять задачами через панель справа.
|
||||
<p class="text-slate-800 text-xs sm:text-sm leading-relaxed">
|
||||
Привет! У каждого пользователя свое изолированное пространство задач. Вы можете задавать вопросы нейросети, прикреплять файлы или ставить персональные задачи.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Поле ввода с авто-высотой, отключенным автокомплитом и сохраненной историей -->
|
||||
<div class="p-4 bg-white border-t border-slate-200">
|
||||
<form id="chat-form" onsubmit="sendMessage(event)" class="flex items-end gap-2">
|
||||
<div class="flex-1 bg-slate-50 border border-slate-300 rounded-lg p-1 focus-within:border-indigo-600 focus-within:bg-white transition">
|
||||
<!-- Превью прикрепленного файла -->
|
||||
<div id="file-preview-container" class="hidden px-4 py-2 bg-slate-100 border-t border-slate-200 flex items-center justify-between text-xs text-slate-700">
|
||||
<div class="flex items-center gap-2 truncate">
|
||||
<i class="fa-solid fa-paperclip text-indigo-600"></i>
|
||||
<span id="file-name-display" class="font-medium truncate">file.pdf</span>
|
||||
<span id="file-size-display" class="text-slate-400 text-[10px]">(0 KB)</span>
|
||||
</div>
|
||||
<button type="button" onclick="clearAttachedFile()" class="text-slate-400 hover:text-red-500 p-1 transition">
|
||||
<i class="fa-solid fa-xmark text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-2.5 pb-6 bg-white border-t border-slate-200 shrink-0 z-10 shadow-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Скрытый инпут и кнопка прикрепления файла -->
|
||||
<input type="file" id="file-input" class="hidden" onchange="handleFileSelect(event)" accept=".png,.jpg,.jpeg,.pdf,.txt,.csv,.xlsx">
|
||||
<button type="button" onclick="document.getElementById('file-input').click()" class="text-slate-500 hover:text-indigo-600 p-2 rounded-xl transition" title="Прикрепить файл">
|
||||
<i class="fa-solid fa-paperclip text-lg"></i>
|
||||
</button>
|
||||
|
||||
<div class="flex-1 bg-slate-100 border border-slate-300 rounded-2xl px-3 py-1.5 focus-within:border-indigo-600 focus-within:bg-white transition">
|
||||
<textarea id="user-input" rows="1" autocomplete="off" autocorrect="off" spellcheck="false"
|
||||
placeholder="Команда или вопрос (Shift+Enter — новая строка, ↑/↓ — история)..."
|
||||
class="w-full bg-transparent text-slate-800 px-3 py-1.5 text-sm focus:outline-none resize-none overflow-y-auto max-h-[96px] leading-relaxed fade-scroll-top no-scrollbar"></textarea>
|
||||
placeholder="Команда, вопрос или перетащите файл сюда..."
|
||||
class="w-full bg-transparent text-slate-900 text-sm focus:outline-none resize-none overflow-y-auto h-[24px] max-h-[120px] leading-[24px] fade-scroll-top no-scrollbar"></textarea>
|
||||
</div>
|
||||
<button type="submit" id="send-btn" class="bg-indigo-600 hover:bg-indigo-700 text-white font-medium px-5 h-[42px] rounded-lg text-sm transition flex items-center gap-2 shrink-0">
|
||||
<button type="button" id="send-btn" onclick="sendMessage()" class="bg-indigo-600 active:bg-indigo-800 text-white font-semibold px-3.5 py-2.5 rounded-2xl text-xs sm:text-sm transition flex items-center justify-center gap-1.5 shrink-0 shadow-sm">
|
||||
<span>Отправить</span>
|
||||
<i class="fa-solid fa-paper-plane text-xs"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Выезжающая справа панель (Drawer) -->
|
||||
<div id="drawer-backdrop" onclick="toggleDrawer()" class="fixed inset-0 bg-slate-900/30 backdrop-blur-sm hidden transition-opacity z-40"></div>
|
||||
<!-- Выезжающая панель (Drawer) -->
|
||||
<div id="drawer-backdrop" onclick="toggleDrawer()" class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm hidden transition-opacity z-40"></div>
|
||||
|
||||
<aside id="task-drawer" class="fixed right-0 top-0 h-full w-[420px] bg-white border-l border-slate-200 shadow-2xl transform translate-x-full transition-transform duration-300 ease-in-out z-50 flex flex-col">
|
||||
|
||||
<div class="p-4 border-b border-slate-200 flex justify-between items-center bg-slate-50">
|
||||
<aside id="task-drawer" class="fixed right-0 top-0 h-full w-full sm:w-[420px] bg-white border-l border-slate-200 shadow-2xl transform translate-x-full transition-transform duration-300 ease-in-out z-50 flex flex-col">
|
||||
<div class="p-3.5 border-b border-slate-200 flex justify-between items-center bg-slate-50 shrink-0">
|
||||
<h2 class="font-bold text-slate-800 flex items-center gap-2 text-sm">
|
||||
<i class="fa-solid fa-list-check text-indigo-600"></i> Реестр задач
|
||||
<i class="fa-solid fa-list-check text-indigo-600"></i> Мой реестр задач
|
||||
</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<button onclick="loadTasks()" class="text-xs text-slate-500 hover:text-indigo-600 transition p-1" title="Обновить">
|
||||
<i class="fa-solid fa-rotate-right"></i>
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="button" onclick="loadTasks()" class="text-xs text-slate-500 hover:text-indigo-600 transition p-1" title="Обновить">
|
||||
<i class="fa-solid fa-rotate-right text-sm"></i>
|
||||
</button>
|
||||
<button onclick="toggleDrawer()" class="text-slate-400 hover:text-slate-700 transition p-1">
|
||||
<button type="button" onclick="toggleDrawer()" class="text-slate-500 hover:text-slate-800 transition p-1">
|
||||
<i class="fa-solid fa-xmark text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Фильтры задач -->
|
||||
<div class="flex border-b border-slate-200 bg-white px-2 pt-2 text-xs font-medium text-slate-500 gap-1">
|
||||
<button onclick="setFilter('ALL')" id="filter-ALL" class="px-3 py-1.5 rounded-t-lg border-b-2 border-indigo-600 text-indigo-600 font-bold">Все</button>
|
||||
<button onclick="setFilter('IN_PROGRESS')" id="filter-IN_PROGRESS" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent hover:text-slate-700">В работе</button>
|
||||
<button onclick="setFilter('BACKLOG')" id="filter-BACKLOG" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent hover:text-slate-700">Бэклог</button>
|
||||
<button onclick="setFilter('COMPLETED')" id="filter-COMPLETED" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent hover:text-slate-700">Завершено</button>
|
||||
<div class="flex border-b border-slate-200 bg-white px-2 pt-2 text-xs font-semibold text-slate-500 gap-1 overflow-x-auto no-scrollbar shrink-0">
|
||||
<button type="button" onclick="setFilter('ALL')" id="filter-ALL" class="px-3 py-1.5 rounded-t-lg border-b-2 border-indigo-600 text-indigo-600 font-bold whitespace-nowrap">Все</button>
|
||||
<button type="button" onclick="setFilter('IN_PROGRESS')" id="filter-IN_PROGRESS" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap">В работе</button>
|
||||
<button type="button" onclick="setFilter('BACKLOG')" id="filter-BACKLOG" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap">Бэклог</button>
|
||||
<button type="button" onclick="setFilter('COMPLETED')" id="filter-COMPLETED" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap">Завершено</button>
|
||||
</div>
|
||||
|
||||
<div id="tasks-container" class="flex-1 overflow-y-auto p-4 space-y-3 bg-slate-50/50">
|
||||
<div class="text-center text-slate-400 py-8 text-xs">Загрузка задач...</div>
|
||||
<div id="tasks-container" class="flex-1 overflow-y-auto p-3.5 space-y-3 bg-slate-50/50 pb-8">
|
||||
<div class="text-center text-slate-400 py-8 text-xs">Загрузка ваших задач...</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<script>
|
||||
const API_TOKEN = "scud_secret_token_2026";
|
||||
const SESSION_ID = "web_session_main";
|
||||
const STORAGE_KEY = 'scud_chat_input_history';
|
||||
|
||||
let currentFilter = 'ALL';
|
||||
let allTasks = [];
|
||||
|
||||
// === ИСТОРИЯ КОМАНД (LOCALSTORAGE + СТРЕЛКИ ВВЕРХ/ВНИЗ) ===
|
||||
let inputHistory = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
|
||||
let historyIndex = -1;
|
||||
|
||||
const userInputEl = document.getElementById('user-input');
|
||||
|
||||
if (userInputEl) {
|
||||
// Динамическое расширение высоты поля до 4 строк (~96px)
|
||||
userInputEl.addEventListener('input', function() {
|
||||
this.style.height = 'auto';
|
||||
this.style.height = Math.min(this.scrollHeight, 96) + 'px';
|
||||
});
|
||||
|
||||
userInputEl.addEventListener('keydown', function(e) {
|
||||
// Отправка по Enter (без Shift)
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
document.getElementById('chat-form').requestSubmit();
|
||||
}
|
||||
// Стрелка ВВЕРХ — с первого нажатия забираем предыдущую команду
|
||||
else if (e.key === 'ArrowUp') {
|
||||
if (inputHistory.length > 0 && historyIndex < inputHistory.length - 1) {
|
||||
e.preventDefault();
|
||||
if (historyIndex === -1) {
|
||||
this.dataset.draft = this.value;
|
||||
}
|
||||
historyIndex++;
|
||||
this.value = inputHistory[inputHistory.length - 1 - historyIndex];
|
||||
this.dispatchEvent(new Event('input'));
|
||||
setTimeout(() => this.setSelectionRange(this.value.length, this.value.length), 0);
|
||||
}
|
||||
}
|
||||
// Стрелка ВНИЗ — Навигация к более новым командам
|
||||
else if (e.key === 'ArrowDown') {
|
||||
if (historyIndex !== -1) {
|
||||
e.preventDefault();
|
||||
if (historyIndex > 0) {
|
||||
historyIndex--;
|
||||
this.value = inputHistory[inputHistory.length - 1 - historyIndex];
|
||||
} else {
|
||||
historyIndex = -1;
|
||||
this.value = this.dataset.draft || '';
|
||||
}
|
||||
this.dispatchEvent(new Event('input'));
|
||||
setTimeout(() => this.setSelectionRange(this.value.length, this.value.length), 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleDrawer() {
|
||||
const drawer = document.getElementById('task-drawer');
|
||||
const backdrop = document.getElementById('drawer-backdrop');
|
||||
const isHidden = drawer.classList.contains('translate-x-full');
|
||||
if (isHidden) {
|
||||
drawer.classList.remove('translate-x-full');
|
||||
backdrop.classList.remove('hidden');
|
||||
} else {
|
||||
drawer.classList.add('translate-x-full');
|
||||
backdrop.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function setFilter(status) {
|
||||
currentFilter = status;
|
||||
['ALL', 'IN_PROGRESS', 'BACKLOG', 'COMPLETED'].forEach(f => {
|
||||
const btn = document.getElementById(`filter-${f}`);
|
||||
if (f === status) {
|
||||
btn.className = "px-3 py-1.5 rounded-t-lg border-b-2 border-indigo-600 text-indigo-600 font-bold";
|
||||
} else {
|
||||
btn.className = "px-3 py-1.5 rounded-t-lg border-b-2 border-transparent hover:text-slate-700";
|
||||
}
|
||||
});
|
||||
renderTasks();
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/tasks', {
|
||||
headers: { 'Authorization': `Bearer ${API_TOKEN}` }
|
||||
});
|
||||
allTasks = await res.json();
|
||||
document.getElementById('task-count-badge').innerText = allTasks.length;
|
||||
renderTasks();
|
||||
} catch (err) {
|
||||
document.getElementById('tasks-container').innerHTML = `<div class="text-red-500 text-xs py-4 text-center">Ошибка загрузки задач</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTasks() {
|
||||
const container = document.getElementById('tasks-container');
|
||||
const filtered = allTasks.filter(t => currentFilter === 'ALL' || t.status === currentFilter);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
container.innerHTML = `<div class="text-slate-400 text-xs py-8 text-center">Нет задач с выбранным фильтром</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = filtered.map(t => {
|
||||
let statusBadge = 'bg-slate-100 text-slate-600 border-slate-200';
|
||||
let cardBg = 'bg-white';
|
||||
if (t.status === 'COMPLETED') {
|
||||
statusBadge = 'bg-emerald-50 text-emerald-700 border-emerald-300 font-semibold';
|
||||
cardBg = 'bg-emerald-50/20';
|
||||
} else if (t.status === 'IN_PROGRESS') {
|
||||
statusBadge = 'bg-amber-50 text-amber-700 border-amber-300 font-bold';
|
||||
cardBg = 'bg-amber-50/20 border-amber-200';
|
||||
}
|
||||
|
||||
let priorityBadge = 'text-slate-500 bg-slate-100 border-slate-200';
|
||||
if (t.priority === 'HIGH') priorityBadge = 'text-red-700 bg-red-50 border-red-200 font-bold';
|
||||
|
||||
let dueDateHtml = '';
|
||||
if (t.due_date) {
|
||||
dueDateHtml = `
|
||||
<div class="mt-2 text-[11px] text-amber-800 bg-amber-50 border border-amber-200 px-2 py-0.5 rounded-md flex items-center gap-1.5 w-fit font-medium">
|
||||
<i class="fa-solid fa-clock text-amber-600"></i>
|
||||
<span>Срок: ${t.due_date}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="${cardBg} border border-slate-200 rounded-xl p-3.5 shadow-sm hover:shadow-md transition">
|
||||
<div class="flex justify-between items-center mb-1.5">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-mono text-xs font-bold text-slate-900 bg-slate-100 px-2 py-0.5 rounded border border-slate-200">${t.task_id}</span>
|
||||
<span class="text-[10px] uppercase px-1.5 py-0.5 rounded border ${priorityBadge}">${t.priority || 'HIGH'}</span>
|
||||
</div>
|
||||
<span class="text-[10px] uppercase px-2 py-0.5 rounded border ${statusBadge}">${t.status}</span>
|
||||
</div>
|
||||
|
||||
<h3 class="text-xs font-semibold text-slate-800 mb-1 leading-snug">${t.title}</h3>
|
||||
|
||||
<div class="text-[10px] text-slate-400 font-mono flex items-center gap-1">
|
||||
<i class="fa-solid fa-folder-closed text-slate-300"></i>
|
||||
<span>${t.module}</span>
|
||||
</div>
|
||||
|
||||
${dueDateHtml}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function sendMessage(e) {
|
||||
e.preventDefault();
|
||||
const input = document.getElementById('user-input');
|
||||
const chatWindow = document.getElementById('chat-window');
|
||||
const sendBtn = document.getElementById('send-btn');
|
||||
const text = input.value.trim();
|
||||
|
||||
if (!text) return;
|
||||
|
||||
// Сохранение истории команд в localStorage (до 50 записей)
|
||||
if (inputHistory[inputHistory.length - 1] !== text) {
|
||||
inputHistory.push(text);
|
||||
if (inputHistory.length > 50) inputHistory.shift();
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(inputHistory));
|
||||
}
|
||||
historyIndex = -1;
|
||||
|
||||
chatWindow.innerHTML += `
|
||||
<div class="flex justify-end">
|
||||
<div class="bg-indigo-600 text-white rounded-xl px-4 py-2.5 max-w-2xl text-sm shadow-sm">
|
||||
${text}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
input.value = '';
|
||||
input.style.height = 'auto';
|
||||
chatWindow.scrollTop = chatWindow.scrollHeight;
|
||||
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.classList.add('opacity-50');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/chat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${API_TOKEN}`
|
||||
},
|
||||
body: JSON.stringify({ session_id: SESSION_ID, message: text })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
chatWindow.innerHTML += `
|
||||
<div class="bg-white border border-slate-200 rounded-xl p-4 max-w-2xl shadow-sm">
|
||||
<p class="text-xs font-bold text-indigo-600 uppercase tracking-wider mb-1"><i class="fa-solid fa-robot mr-1"></i> ИИ-Ассистент</p>
|
||||
<p class="text-slate-700 text-sm whitespace-pre-wrap leading-relaxed">${data.reply}</p>
|
||||
</div>
|
||||
`;
|
||||
chatWindow.scrollTop = chatWindow.scrollHeight;
|
||||
loadTasks();
|
||||
|
||||
} catch (err) {
|
||||
chatWindow.innerHTML += `
|
||||
<div class="bg-red-50 border border-red-200 rounded-xl p-4 max-w-2xl text-red-700 text-sm">
|
||||
Ошибка связи с сервером API.
|
||||
</div>
|
||||
`;
|
||||
} finally {
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.classList.remove('opacity-50');
|
||||
}
|
||||
}
|
||||
|
||||
loadTasks();
|
||||
</script>
|
||||
<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/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,36 @@
|
||||
const AUTH_TOKEN_KEY = "scud_api_auth_token";
|
||||
const SESSION_ID = "web_session_main";
|
||||
const STORAGE_KEY = "scud_chat_input_history";
|
||||
|
||||
let API_TOKEN = localStorage.getItem(AUTH_TOKEN_KEY) || "";
|
||||
let CURRENT_USERNAME = localStorage.getItem("scud_username") || "";
|
||||
let IS_ADMIN = localStorage.getItem("scud_is_admin") === "true";
|
||||
let IS_GUEST = localStorage.getItem("scud_is_guest") === "true";
|
||||
|
||||
let inputHistory = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
|
||||
let historyIndex = -1;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const userInputEl = document.getElementById("user-input");
|
||||
|
||||
if (userInputEl) {
|
||||
userInputEl.addEventListener("input", function() {
|
||||
this.style.height = "24px";
|
||||
const newHeight = Math.min(this.scrollHeight, 120);
|
||||
this.style.height = newHeight + "px";
|
||||
});
|
||||
}
|
||||
|
||||
if (IS_GUEST) {
|
||||
hideAuthModal();
|
||||
updateUIState();
|
||||
} else if (API_TOKEN) {
|
||||
hideAuthModal();
|
||||
updateUIState();
|
||||
if (typeof loadTasks === "function") {
|
||||
loadTasks();
|
||||
}
|
||||
} else {
|
||||
showAuthModal();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
function showAuthModal() {
|
||||
const el = document.getElementById("auth-modal");
|
||||
if (el) el.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function hideAuthModal() {
|
||||
const el = document.getElementById("auth-modal");
|
||||
if (el) el.classList.add("hidden");
|
||||
}
|
||||
|
||||
async function handleLogin(e) {
|
||||
if (e && e.preventDefault) e.preventDefault();
|
||||
|
||||
const usernameInput = document.getElementById("auth-username-input");
|
||||
const passwordInput = document.getElementById("auth-password-input");
|
||||
const errorEl = document.getElementById("auth-error");
|
||||
|
||||
if (!usernameInput || !passwordInput) return;
|
||||
|
||||
const username = usernameInput.value.trim();
|
||||
const password = passwordInput.value;
|
||||
|
||||
if (!username || !password) return;
|
||||
|
||||
if (errorEl) errorEl.classList.add("hidden");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/v1/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.status === 200) {
|
||||
// Используем прямой строковый ключ, чтобы избежать ошибки ReferenceError
|
||||
API_TOKEN = data.token;
|
||||
CURRENT_USERNAME = data.username;
|
||||
IS_ADMIN = data.is_admin;
|
||||
IS_GUEST = false;
|
||||
|
||||
localStorage.setItem("scud_api_auth_token", data.token);
|
||||
localStorage.setItem("scud_username", data.username);
|
||||
localStorage.setItem("scud_is_admin", data.is_admin ? "true" : "false");
|
||||
localStorage.removeItem("scud_is_guest");
|
||||
|
||||
hideAuthModal();
|
||||
updateUIState();
|
||||
|
||||
if (typeof loadTasks === 'function') {
|
||||
loadTasks();
|
||||
}
|
||||
} else {
|
||||
if (errorEl) {
|
||||
errorEl.innerText = data.detail || "Ошибка авторизации";
|
||||
errorEl.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Auth Error]", err);
|
||||
if (errorEl) {
|
||||
errorEl.innerText = "Ошибка соединения с сервером";
|
||||
errorEl.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function enableGuestMode() {
|
||||
IS_GUEST = true;
|
||||
API_TOKEN = "";
|
||||
CURRENT_USERNAME = "Гость";
|
||||
IS_ADMIN = false;
|
||||
localStorage.setItem("scud_is_guest", "true");
|
||||
hideAuthModal();
|
||||
updateUIState();
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem("scud_api_auth_token");
|
||||
localStorage.removeItem("scud_username");
|
||||
localStorage.removeItem("scud_is_admin");
|
||||
localStorage.removeItem("scud_is_guest");
|
||||
API_TOKEN = "";
|
||||
CURRENT_USERNAME = "";
|
||||
IS_ADMIN = false;
|
||||
IS_GUEST = false;
|
||||
showAuthModal();
|
||||
}
|
||||
|
||||
function updateUIState() {
|
||||
const tasksBtn = document.getElementById("tasks-drawer-btn");
|
||||
const adminBtn = document.getElementById("admin-users-btn");
|
||||
const changePwdBtn = document.getElementById("change-pwd-btn");
|
||||
const guestBadge = document.getElementById("guest-badge");
|
||||
const usernameBadge = document.getElementById("username-badge");
|
||||
|
||||
if (typeof IS_GUEST !== 'undefined' && IS_GUEST) {
|
||||
if (tasksBtn) tasksBtn.classList.add("hidden");
|
||||
if (adminBtn) adminBtn.classList.add("hidden");
|
||||
if (changePwdBtn) changePwdBtn.classList.add("hidden");
|
||||
if (guestBadge) guestBadge.classList.remove("hidden");
|
||||
if (usernameBadge) usernameBadge.classList.add("hidden");
|
||||
} else {
|
||||
if (tasksBtn) tasksBtn.classList.remove("hidden");
|
||||
if (changePwdBtn) changePwdBtn.classList.remove("hidden");
|
||||
if (guestBadge) guestBadge.classList.add("hidden");
|
||||
|
||||
if (usernameBadge) {
|
||||
usernameBadge.innerText = (typeof CURRENT_USERNAME !== 'undefined' && CURRENT_USERNAME) ? CURRENT_USERNAME : "User";
|
||||
usernameBadge.classList.remove("hidden");
|
||||
}
|
||||
|
||||
if (adminBtn) {
|
||||
const isAdminUser = (typeof IS_ADMIN !== 'undefined' && IS_ADMIN) || (typeof CURRENT_USERNAME !== 'undefined' && CURRENT_USERNAME === "puh");
|
||||
if (isAdminUser) {
|
||||
adminBtn.classList.remove("hidden");
|
||||
} else {
|
||||
adminBtn.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openChangePasswordModal() {
|
||||
const el = document.getElementById("change-pwd-modal");
|
||||
if (el) el.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function closeChangePasswordModal() {
|
||||
const el = document.getElementById("change-pwd-modal");
|
||||
if (el) el.classList.add("hidden");
|
||||
|
||||
const err = document.getElementById("pwd-error");
|
||||
const succ = document.getElementById("pwd-success");
|
||||
if (err) err.classList.add("hidden");
|
||||
if (succ) succ.classList.add("hidden");
|
||||
|
||||
document.getElementById("old-pwd-input").value = "";
|
||||
document.getElementById("new-pwd-input").value = "";
|
||||
const confirmInput = document.getElementById("confirm-pwd-input");
|
||||
if (confirmInput) confirmInput.value = "";
|
||||
}
|
||||
|
||||
async function handleChangePassword(e) {
|
||||
if (e && e.preventDefault) e.preventDefault();
|
||||
const old_password = document.getElementById("old-pwd-input").value;
|
||||
const new_password = document.getElementById("new-pwd-input").value;
|
||||
const confirmInput = document.getElementById("confirm-pwd-input");
|
||||
const confirm_password = confirmInput ? confirmInput.value : new_password;
|
||||
const errorEl = document.getElementById("pwd-error");
|
||||
const successEl = document.getElementById("pwd-success");
|
||||
|
||||
if (errorEl) errorEl.classList.add("hidden");
|
||||
if (successEl) successEl.classList.add("hidden");
|
||||
|
||||
if (new_password !== confirm_password) {
|
||||
if (errorEl) {
|
||||
errorEl.innerText = "Новые пароли не совпадают";
|
||||
errorEl.classList.remove("hidden");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
|
||||
const res = await fetch("/api/v1/auth/change-password", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer " + token
|
||||
},
|
||||
body: JSON.stringify({ old_password, new_password })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.status === 200) {
|
||||
if (successEl) {
|
||||
successEl.innerText = "Пароль успешно изменен!";
|
||||
successEl.classList.remove("hidden");
|
||||
}
|
||||
setTimeout(closeChangePasswordModal, 1500);
|
||||
} else {
|
||||
if (errorEl) {
|
||||
errorEl.innerText = data.detail || "Ошибка при смене пароля";
|
||||
errorEl.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (errorEl) {
|
||||
errorEl.innerText = "Ошибка соединения с сервером";
|
||||
errorEl.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openAdminModal() {
|
||||
const el = document.getElementById("admin-modal");
|
||||
if (el) el.classList.remove("hidden");
|
||||
loadUsersList();
|
||||
}
|
||||
|
||||
function closeAdminModal() {
|
||||
const el = document.getElementById("admin-modal");
|
||||
if (el) el.classList.add("hidden");
|
||||
}
|
||||
|
||||
async function loadUsersList() {
|
||||
const listEl = document.getElementById("admin-users-list");
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = '<div class="text-xs text-slate-400 py-4 text-center">Загрузка пользователей...</div>';
|
||||
|
||||
try {
|
||||
const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
|
||||
const res = await fetch("/api/v1/admin/users", {
|
||||
headers: { "Authorization": "Bearer " + token }
|
||||
});
|
||||
const users = await res.json();
|
||||
|
||||
if (res.status === 200) {
|
||||
listEl.innerHTML = users.map(u => {
|
||||
const adminTag = u.is_admin ? '<span class="ml-1.5 text-[9px] bg-indigo-100 text-indigo-700 px-1.5 py-0.5 rounded font-bold">ADMIN</span>' : '<span class="ml-1.5 text-[9px] bg-slate-100 text-slate-600 px-1.5 py-0.5 rounded">USER</span>';
|
||||
const fullNameHtml = u.full_name ? `<div class="text-[11px] text-slate-500 font-normal">${u.full_name}</div>` : '';
|
||||
const dateStr = u.created_at ? u.created_at.split(' ')[0] : '—';
|
||||
const deleteBtn = u.username !== CURRENT_USERNAME ? `<button type="button" onclick="deleteUser(${u.id}, '${u.username}')" class="text-red-500 hover:text-red-700 p-1"><i class="fa-solid fa-trash-can"></i></button>` : '<span class="text-[10px] text-slate-400">Вы</span>';
|
||||
|
||||
return `
|
||||
<div class="flex justify-between items-center bg-slate-50 border border-slate-200 p-2.5 rounded-xl text-xs">
|
||||
<div>
|
||||
<div class="flex items-center">
|
||||
<span class="font-bold text-slate-800">${u.username}</span>
|
||||
${adminTag}
|
||||
</div>
|
||||
${fullNameHtml}
|
||||
<div class="text-[10px] text-slate-400 mt-0.5">Создан: ${dateStr}</div>
|
||||
</div>
|
||||
${deleteBtn}
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
} else {
|
||||
listEl.innerHTML = `<div class="text-xs text-red-500 py-2">${users.detail}</div>`;
|
||||
}
|
||||
} catch (err) {
|
||||
listEl.innerHTML = '<div class="text-xs text-red-500 py-2">Ошибка загрузки пользователей</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateUser(e) {
|
||||
if (e && e.preventDefault) e.preventDefault();
|
||||
const username = document.getElementById("new-user-name").value.trim();
|
||||
const password = document.getElementById("new-user-pwd").value;
|
||||
const fullNameInput = document.getElementById("new-user-fullname");
|
||||
const full_name = fullNameInput ? fullNameInput.value.trim() : "";
|
||||
const adminCheckbox = document.getElementById("new-user-is-admin");
|
||||
const is_admin = adminCheckbox ? adminCheckbox.checked : false;
|
||||
const msgEl = document.getElementById("admin-msg");
|
||||
|
||||
if (msgEl) msgEl.classList.add("hidden");
|
||||
|
||||
try {
|
||||
const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
|
||||
const res = await fetch("/api/v1/admin/users", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer " + token
|
||||
},
|
||||
body: JSON.stringify({ username, password, full_name, is_admin })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.status === 200) {
|
||||
document.getElementById("new-user-name").value = "";
|
||||
document.getElementById("new-user-pwd").value = "";
|
||||
if (fullNameInput) fullNameInput.value = "";
|
||||
if (adminCheckbox) adminCheckbox.checked = false;
|
||||
loadUsersList();
|
||||
} else {
|
||||
if (msgEl) {
|
||||
msgEl.innerText = data.detail || "Ошибка";
|
||||
msgEl.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (msgEl) {
|
||||
msgEl.innerText = "Ошибка связи с сервером";
|
||||
msgEl.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(userId, username) {
|
||||
if (!confirm("Удалить пользователя " + username + "?")) return;
|
||||
|
||||
try {
|
||||
const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
|
||||
await fetch("/api/v1/admin/users/" + userId, {
|
||||
method: "DELETE",
|
||||
headers: { "Authorization": "Bearer " + token }
|
||||
});
|
||||
loadUsersList();
|
||||
} catch (err) {
|
||||
alert("Ошибка при удалении");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
// Вспомогательная функция для автоматического изменения высоты текстового поля
|
||||
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 sendQuickAction(actionText) {
|
||||
const input = document.getElementById("user-input");
|
||||
if (input) {
|
||||
input.value = actionText;
|
||||
sendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Удаляем старые активные плашки кнопок, чтобы не висели предыдущие подтверждения
|
||||
document.querySelectorAll('.confirm-action-bar').forEach(el => el.remove());
|
||||
|
||||
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 || "Пустой ответ от нейросети";
|
||||
|
||||
// Кнопки генерируем СТРОГО если бэкенд вернул флаг data.needs_confirmation === true
|
||||
let buttonsHtml = "";
|
||||
if (data.needs_confirmation === true) {
|
||||
buttonsHtml = `
|
||||
<div class="confirm-action-bar mt-3 pt-2.5 border-t border-slate-200 flex items-center justify-end gap-2">
|
||||
<button type="button" onclick="sendQuickAction('отмена')"
|
||||
class="bg-slate-100 hover:bg-slate-200 active:bg-slate-300 text-slate-700 font-semibold px-3 py-1.5 rounded-xl text-xs transition border border-slate-300 flex items-center gap-1.5 shadow-sm">
|
||||
<i class="fa-solid fa-xmark text-red-500"></i>
|
||||
<span>Отменить</span>
|
||||
</button>
|
||||
<button type="button" onclick="sendQuickAction('подтверждаю')"
|
||||
class="bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white font-semibold px-3.5 py-1.5 rounded-xl text-xs transition flex items-center gap-1.5 shadow-md">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
<span>Подтвердить</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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>
|
||||
${buttonsHtml}
|
||||
</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;
|
||||
} finally {
|
||||
if (sendBtn) {
|
||||
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();
|
||||
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,145 @@
|
||||
let currentFilter = 'ALL';
|
||||
let allTasks = [];
|
||||
|
||||
function toggleDrawer() {
|
||||
if (typeof IS_GUEST !== 'undefined' && IS_GUEST) return;
|
||||
const drawer = document.getElementById("task-drawer");
|
||||
const backdrop = document.getElementById("drawer-backdrop");
|
||||
if (!drawer) return;
|
||||
|
||||
const isHidden = drawer.classList.contains("translate-x-full");
|
||||
if (isHidden) {
|
||||
drawer.classList.remove("translate-x-full");
|
||||
if (backdrop) backdrop.classList.remove("hidden");
|
||||
loadTasks();
|
||||
} else {
|
||||
drawer.classList.add("translate-x-full");
|
||||
if (backdrop) backdrop.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function setFilter(status) {
|
||||
currentFilter = status;
|
||||
["ALL", "IN_PROGRESS", "BACKLOG", "COMPLETED"].forEach(f => {
|
||||
const btn = document.getElementById(`filter-${f}`);
|
||||
if (btn) {
|
||||
btn.className = (f === status)
|
||||
? "px-3 py-1.5 rounded-t-lg border-b-2 border-indigo-600 text-indigo-600 font-bold whitespace-nowrap"
|
||||
: "px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap";
|
||||
}
|
||||
});
|
||||
renderTasks();
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
const badge = document.getElementById("task-count-badge");
|
||||
const container = document.getElementById("tasks-container");
|
||||
|
||||
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
|
||||
const isGuest = typeof IS_GUEST !== 'undefined' ? IS_GUEST : (localStorage.getItem("scud_is_guest") === "true");
|
||||
|
||||
if (isGuest || !token) {
|
||||
if (badge) badge.innerText = "0";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/v1/tasks", {
|
||||
headers: {
|
||||
"Authorization": "Bearer " + token,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
if (typeof logout === 'function') logout();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// Гибкое определение структуры данных (массив или объект с ключом tasks)
|
||||
if (Array.isArray(data)) {
|
||||
allTasks = data;
|
||||
} else if (data && Array.isArray(data.tasks)) {
|
||||
allTasks = data.tasks;
|
||||
} else if (data && typeof data === 'object') {
|
||||
allTasks = Object.values(data).find(val => Array.isArray(val)) || [];
|
||||
} else {
|
||||
allTasks = [];
|
||||
}
|
||||
|
||||
if (badge) {
|
||||
badge.innerText = allTasks.length.toString();
|
||||
}
|
||||
|
||||
renderTasks();
|
||||
|
||||
} catch (err) {
|
||||
console.error("[Tasks Error]", err);
|
||||
if (badge) badge.innerText = "0";
|
||||
if (container) {
|
||||
container.innerHTML = `<div class="text-red-500 text-xs py-8 text-center font-medium">Ошибка обработки списка задач</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderTasks() {
|
||||
const container = document.getElementById("tasks-container");
|
||||
if (!container) return;
|
||||
|
||||
if (!Array.isArray(allTasks)) {
|
||||
allTasks = [];
|
||||
}
|
||||
|
||||
const filtered = allTasks.filter(t => currentFilter === "ALL" || t.status === currentFilter);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
container.innerHTML = `<div class="text-slate-400 text-xs py-8 text-center">Нет задач с выбранным фильтром</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = filtered.map(t => {
|
||||
let statusBadge = "bg-slate-100 text-slate-600 border-slate-200";
|
||||
let cardBg = "bg-white";
|
||||
|
||||
if (t.status === "COMPLETED") {
|
||||
statusBadge = "bg-emerald-50 text-emerald-700 border-emerald-300 font-semibold";
|
||||
cardBg = "bg-emerald-50/20";
|
||||
} else if (t.status === "IN_PROGRESS") {
|
||||
statusBadge = "bg-amber-50 text-amber-700 border-amber-300 font-bold";
|
||||
cardBg = "bg-amber-50/20 border-amber-200";
|
||||
}
|
||||
|
||||
let priorityBadge = "text-slate-500 bg-slate-100 border-slate-200";
|
||||
if (t.priority === "HIGH") priorityBadge = "text-red-700 bg-red-50 border-red-200 font-bold";
|
||||
|
||||
let dueDateHtml = t.due_date ? `
|
||||
<div class="mt-2 text-[11px] text-amber-800 bg-amber-50 border border-amber-200 px-2 py-0.5 rounded-md flex items-center gap-1.5 w-fit font-medium">
|
||||
<i class="fa-solid fa-clock text-amber-600"></i>
|
||||
<span>Срок: ${t.due_date}</span>
|
||||
</div>` : "";
|
||||
|
||||
return `
|
||||
<div class="${cardBg} border border-slate-200 rounded-xl p-3.5 shadow-sm hover:shadow-md transition">
|
||||
<div class="flex justify-between items-center mb-1.5">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-mono text-xs font-bold text-slate-900 bg-slate-100 px-2 py-0.5 rounded border border-slate-200">${t.task_id || t.id || 'TASK'}</span>
|
||||
<span class="text-[10px] uppercase px-1.5 py-0.5 rounded border ${priorityBadge}">${t.priority || 'MEDIUM'}</span>
|
||||
</div>
|
||||
<span class="text-[10px] uppercase px-2 py-0.5 rounded border ${statusBadge}">${t.status || 'BACKLOG'}</span>
|
||||
</div>
|
||||
<h3 class="text-xs font-semibold text-slate-800 mb-1 leading-snug">${t.title || t.description || ''}</h3>
|
||||
<div class="text-[10px] text-slate-400 font-mono flex items-center gap-1">
|
||||
<i class="fa-solid fa-folder-closed text-slate-300"></i>
|
||||
<span>${t.module || 'General'}</span>
|
||||
</div>
|
||||
${dueDateHtml}
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
python3 -c "
|
||||
import sqlite3
|
||||
|
||||
db_path = '/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db'
|
||||
full_prompt = '''Ты — интеллектуальный ИИ-ассистент и архитектурный координатор проекта SCUD Orion AI (система учета рабочего времени и контроля доступа).
|
||||
|
||||
1. РОЛЬ И ЗАДАЧИ АССИСТЕНТА
|
||||
1.1. Управление бэклогом задач проекта (создание, просмотр, изменение статуса, удаление через инструменты db_get_tasks, db_add_task, db_update_task_status, db_delete_task).
|
||||
1.2. Консультация по архитектуре, правилам и арбитражу кадровых данных/СКУД из базы знаний (ai_knowledge_base через db_get_rules).
|
||||
1.3. Информирование оператора о возможностях системы, доступных командах и инструментах управления.
|
||||
1.4. Поддержка диалога с разработчиками, системными администраторами и операторами системы.
|
||||
|
||||
2. ПРАВИЛА АНАЛИЗА И ВЫЗОВА ИНСТРУМЕНТОВ
|
||||
2.1. Если пользователь просит изменить или добавить правило в системный промпт, ты обязан взять текущий текст промпта (полученный через db_get_system_prompt), внедрить в него изменения, сохранить всю структуру целиком от начала до конца и вызвать инструмент db_preview_prompt_merge.
|
||||
2.2. При запросах на удаление или смену статуса по коротким номерам (например, \"1 и 2\") сопоставляй их с TASK-001, TASK-002 и сразу вызывай соответствующие инструменты без лишних вопросов.
|
||||
2.3. Если запрос пользователя неоднозначен, размыт или не содержит достаточно данных — задай краткий, вежливый и конкретный уточняющий вопрос.
|
||||
|
||||
3. ПРАВИЛА ФОРМАТИРОВАНИЯ И СТИЛЯ
|
||||
3.1. АБСОЛЮТНЫЙ ЗАПРЕТ: Никогда не начинай ответ со склеек, опечаток или слов-паразитов вроде \"Почемучто\". Ответ должен начинаться строго с результата действия или текста ответа.
|
||||
3.2. По умолчанию отвечай строго в чистом текстовом формате (plain text). Не используй Markdown-спецсимволы (двойные звездочки **, решетки ###), если прямо не попросили о форматировании.
|
||||
3.3. Сохраняй инженерный, лаконичный и профессиональный стиль.'''
|
||||
3.4. ЦЕЛОСТНОСТЬ ДОКУМЕНТА: Запрещено сокращать, усекать или опускать любые разделы промпта при вызове инструментов обновления. Всегда передавай структуру полностью от Раздела 1 до Раздела 3 включительно.
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('DELETE FROM system_prompts WHERE name = ?', ('main_agent',))
|
||||
cursor.execute('INSERT INTO system_prompts (name, prompt_text, is_active) VALUES (?, ?, 1)', ('main_agent', full_prompt))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print('[✓] Полный промпт успешно восстановлен в БД.')
|
||||
"
|
||||
|
||||
python3 -c '
|
||||
import sqlite3
|
||||
from passlib.context import CryptContext
|
||||
|
||||
db_path = "/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db"
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 1. Создаем таблицу пользователей, если ее не было
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER DEFAULT 0,
|
||||
full_name TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
""")
|
||||
|
||||
# 2. Создаем хэш пароля (например, для пароля "admin" или вашего текущего)
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
# Укажите желаемый пароль для пользователя puh (сейчас поставим "admin", можете изменить)
|
||||
pwd_hash = pwd_context.hash("M@n0raga")
|
||||
|
||||
# 3. Вставляем или обновляем пользователя puh
|
||||
cursor.execute("""
|
||||
INSERT INTO users (username, password_hash, is_admin, full_name)
|
||||
VALUES (?, ?, 1, ?)
|
||||
ON CONFLICT(username) DO UPDATE SET
|
||||
password_hash = excluded.password_hash,
|
||||
is_admin = 1;
|
||||
""", ("puh", pwd_hash, "Пушков Александр Александрович"))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print("[✓] Таблица users создана, пользователь puh успешно добавлен в рабочую БД!")
|
||||
'
|
||||
|
||||
|
||||
|
||||
Обнови системный промпт. Измени пункт 4.4. "4.4. ПРЯМОЙ ВЫЗОВ ВРЕМЕНИ: При вопросах про текущую дату, время или день недели ЗАПРЕЩЕНО отвечать текстом или писать названия функций вроде (db_get_current_server_time). Ты ОБЯЗАН мгновенно вызвать инструмент db_get_current_server_time через механизм tool_calls."
|
||||
Executable
+350
@@ -0,0 +1,350 @@
|
||||
cd /home/puh/scud_context_api
|
||||
|
||||
# 1. Создаем необходимые директории для CSS и JS
|
||||
mkdir -p static/css static/js
|
||||
|
||||
# 2. Выносим CSS в static/css/styles.css
|
||||
cat << 'EOF' > static/css/styles.css
|
||||
/* Плавное (градиентное) исчезновение текста сверху при скролле */
|
||||
.fade-scroll-top {
|
||||
mask-image: linear-gradient(to bottom, transparent 0%, black 14px);
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 14px);
|
||||
}
|
||||
|
||||
/* Скрытие стандартного скроллбара */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
EOF
|
||||
|
||||
# 3. Выносим JavaScript в static/js/app.js
|
||||
cat << 'EOF' > static/js/app.js
|
||||
const API_TOKEN = "scud_secret_token_2026";
|
||||
const SESSION_ID = "web_session_main";
|
||||
const STORAGE_KEY = 'scud_chat_input_history';
|
||||
|
||||
let currentFilter = 'ALL';
|
||||
let allTasks = [];
|
||||
|
||||
// === ИСТОРИЯ КОМАНД (LOCALSTORAGE + СТРЕЛКИ ВВЕРХ/ВНИЗ) ===
|
||||
let inputHistory = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
|
||||
let historyIndex = -1;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const userInputEl = document.getElementById('user-input');
|
||||
|
||||
if (userInputEl) {
|
||||
// Динамическое расширение высоты поля до 4 строк (~96px)
|
||||
userInputEl.addEventListener('input', function() {
|
||||
this.style.height = 'auto';
|
||||
this.style.height = Math.min(this.scrollHeight, 96) + 'px';
|
||||
});
|
||||
|
||||
userInputEl.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
document.getElementById('chat-form').requestSubmit();
|
||||
}
|
||||
else if (e.key === 'ArrowUp') {
|
||||
if (inputHistory.length > 0 && historyIndex < inputHistory.length - 1) {
|
||||
e.preventDefault();
|
||||
if (historyIndex === -1) {
|
||||
this.dataset.draft = this.value;
|
||||
}
|
||||
historyIndex++;
|
||||
this.value = inputHistory[inputHistory.length - 1 - historyIndex];
|
||||
this.dispatchEvent(new Event('input'));
|
||||
setTimeout(() => this.setSelectionRange(this.value.length, this.value.length), 0);
|
||||
}
|
||||
}
|
||||
else if (e.key === 'ArrowDown') {
|
||||
if (historyIndex !== -1) {
|
||||
e.preventDefault();
|
||||
if (historyIndex > 0) {
|
||||
historyIndex--;
|
||||
this.value = inputHistory[inputHistory.length - 1 - historyIndex];
|
||||
} else {
|
||||
historyIndex = -1;
|
||||
this.value = this.dataset.draft || '';
|
||||
}
|
||||
this.dispatchEvent(new Event('input'));
|
||||
setTimeout(() => this.setSelectionRange(this.value.length, this.value.length), 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadTasks();
|
||||
});
|
||||
|
||||
function toggleDrawer() {
|
||||
const drawer = document.getElementById('task-drawer');
|
||||
const backdrop = document.getElementById('drawer-backdrop');
|
||||
const isHidden = drawer.classList.contains('translate-x-full');
|
||||
if (isHidden) {
|
||||
drawer.classList.remove('translate-x-full');
|
||||
backdrop.classList.remove('hidden');
|
||||
} else {
|
||||
drawer.classList.add('translate-x-full');
|
||||
backdrop.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function setFilter(status) {
|
||||
currentFilter = status;
|
||||
['ALL', 'IN_PROGRESS', 'BACKLOG', 'COMPLETED'].forEach(f => {
|
||||
const btn = document.getElementById(`filter-${f}`);
|
||||
if (f === status) {
|
||||
btn.className = "px-3 py-1.5 rounded-t-lg border-b-2 border-indigo-600 text-indigo-600 font-bold";
|
||||
} else {
|
||||
btn.className = "px-3 py-1.5 rounded-t-lg border-b-2 border-transparent hover:text-slate-700";
|
||||
}
|
||||
});
|
||||
renderTasks();
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/tasks', {
|
||||
headers: { 'Authorization': `Bearer ${API_TOKEN}` }
|
||||
});
|
||||
allTasks = await res.json();
|
||||
document.getElementById('task-count-badge').innerText = allTasks.length;
|
||||
renderTasks();
|
||||
} catch (err) {
|
||||
document.getElementById('tasks-container').innerHTML = `<div class="text-red-500 text-xs py-4 text-center">Ошибка загрузки задач</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTasks() {
|
||||
const container = document.getElementById('tasks-container');
|
||||
const filtered = allTasks.filter(t => currentFilter === 'ALL' || t.status === currentFilter);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
container.innerHTML = `<div class="text-slate-400 text-xs py-8 text-center">Нет задач с выбранным фильтром</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = filtered.map(t => {
|
||||
let statusBadge = 'bg-slate-100 text-slate-600 border-slate-200';
|
||||
let cardBg = 'bg-white';
|
||||
if (t.status === 'COMPLETED') {
|
||||
statusBadge = 'bg-emerald-50 text-emerald-700 border-emerald-300 font-semibold';
|
||||
cardBg = 'bg-emerald-50/20';
|
||||
} else if (t.status === 'IN_PROGRESS') {
|
||||
statusBadge = 'bg-amber-50 text-amber-700 border-amber-300 font-bold';
|
||||
cardBg = 'bg-amber-50/20 border-amber-200';
|
||||
}
|
||||
|
||||
let priorityBadge = 'text-slate-500 bg-slate-100 border-slate-200';
|
||||
if (t.priority === 'HIGH') priorityBadge = 'text-red-700 bg-red-50 border-red-200 font-bold';
|
||||
|
||||
let dueDateHtml = '';
|
||||
if (t.due_date) {
|
||||
dueDateHtml = `
|
||||
<div class="mt-2 text-[11px] text-amber-800 bg-amber-50 border border-amber-200 px-2 py-0.5 rounded-md flex items-center gap-1.5 w-fit font-medium">
|
||||
<i class="fa-solid fa-clock text-amber-600"></i>
|
||||
<span>Срок: ${t.due_date}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="${cardBg} border border-slate-200 rounded-xl p-3.5 shadow-sm hover:shadow-md transition">
|
||||
<div class="flex justify-between items-center mb-1.5">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-mono text-xs font-bold text-slate-900 bg-slate-100 px-2 py-0.5 rounded border border-slate-200">${t.task_id}</span>
|
||||
<span class="text-[10px] uppercase px-1.5 py-0.5 rounded border ${priorityBadge}">${t.priority || 'HIGH'}</span>
|
||||
</div>
|
||||
<span class="text-[10px] uppercase px-2 py-0.5 rounded border ${statusBadge}">${t.status}</span>
|
||||
</div>
|
||||
|
||||
<h3 class="text-xs font-semibold text-slate-800 mb-1 leading-snug">${t.title}</h3>
|
||||
|
||||
<div class="text-[10px] text-slate-400 font-mono flex items-center gap-1">
|
||||
<i class="fa-solid fa-folder-closed text-slate-300"></i>
|
||||
<span>${t.module}</span>
|
||||
</div>
|
||||
|
||||
${dueDateHtml}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function sendMessage(e) {
|
||||
e.preventDefault();
|
||||
const input = document.getElementById('user-input');
|
||||
const chatWindow = document.getElementById('chat-window');
|
||||
const sendBtn = document.getElementById('send-btn');
|
||||
const text = input.value.trim();
|
||||
|
||||
if (!text) return;
|
||||
|
||||
if (inputHistory[inputHistory.length - 1] !== text) {
|
||||
inputHistory.push(text);
|
||||
if (inputHistory.length > 50) inputHistory.shift();
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(inputHistory));
|
||||
}
|
||||
historyIndex = -1;
|
||||
|
||||
chatWindow.innerHTML += `
|
||||
<div class="flex justify-end">
|
||||
<div class="bg-indigo-600 text-white rounded-xl px-4 py-2.5 max-w-2xl text-sm shadow-sm">
|
||||
${text}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
input.value = '';
|
||||
input.style.height = 'auto';
|
||||
chatWindow.scrollTop = chatWindow.scrollHeight;
|
||||
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.classList.add('opacity-50');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/chat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${API_TOKEN}`
|
||||
},
|
||||
body: JSON.stringify({ session_id: SESSION_ID, message: text })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
chatWindow.innerHTML += `
|
||||
<div class="bg-white border border-slate-200 rounded-xl p-4 max-w-2xl shadow-sm">
|
||||
<p class="text-xs font-bold text-indigo-600 uppercase tracking-wider mb-1"><i class="fa-solid fa-robot mr-1"></i> ИИ-Ассистент</p>
|
||||
<p class="text-slate-700 text-sm whitespace-pre-wrap leading-relaxed">${data.reply}</p>
|
||||
</div>
|
||||
`;
|
||||
chatWindow.scrollTop = chatWindow.scrollHeight;
|
||||
loadTasks();
|
||||
|
||||
} catch (err) {
|
||||
chatWindow.innerHTML += `
|
||||
<div class="bg-red-50 border border-red-200 rounded-xl p-4 max-w-2xl text-red-700 text-sm">
|
||||
Ошибка связи с сервером API.
|
||||
</div>
|
||||
`;
|
||||
} finally {
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.classList.remove('opacity-50');
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# 4. Обновляем чистый static/index.html
|
||||
cat << 'EOF' > static/index.html
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SCUD Orion AI — Context Manager</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/css/styles.css">
|
||||
</head>
|
||||
<body class="bg-slate-50 text-slate-800 h-screen flex flex-col font-sans">
|
||||
|
||||
<!-- Хедер -->
|
||||
<header class="bg-white border-b border-slate-200 px-6 py-3 flex justify-between items-center shadow-sm">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="bg-indigo-600 text-white p-2 rounded-lg">
|
||||
<i class="fa-solid fa-brain text-lg"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-base font-bold leading-none text-slate-900">SCUD Orion AI</h1>
|
||||
<span class="text-xs text-slate-500">Context & Task Manager</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="flex items-center text-xs text-emerald-600 font-medium bg-emerald-50 px-2.5 py-1 rounded-full border border-emerald-200">
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-500 mr-1.5"></span> API Online
|
||||
</span>
|
||||
<button onclick="toggleDrawer()" class="bg-indigo-50 hover:bg-indigo-100 text-indigo-700 px-3.5 py-1.5 rounded-lg text-xs font-semibold flex items-center gap-2 transition border border-indigo-200">
|
||||
<i class="fa-solid fa-list-check text-indigo-600"></i>
|
||||
<span>Реестр задач</span>
|
||||
<span id="task-count-badge" class="bg-indigo-600 text-white text-[10px] px-1.5 py-0.2 rounded-full">0</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Основная зона чата -->
|
||||
<main class="flex-1 flex flex-col max-w-4xl w-full mx-auto bg-white my-4 rounded-xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div id="chat-window" class="flex-1 p-6 overflow-y-auto space-y-4 bg-slate-50/50">
|
||||
<div class="bg-white border border-slate-200 rounded-xl p-4 max-w-2xl shadow-sm">
|
||||
<p class="text-xs font-bold text-indigo-600 uppercase tracking-wider mb-1">
|
||||
<i class="fa-solid fa-robot mr-1"></i> ИИ-Ассистент
|
||||
</p>
|
||||
<p class="text-slate-700 text-sm leading-relaxed">
|
||||
Привет! Я подключен к вашей базе задач и системным промптам. Вы можете писать команды прямо в чат или управлять задачами через панель справа.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Поле ввода -->
|
||||
<div class="p-4 bg-white border-t border-slate-200">
|
||||
<form id="chat-form" onsubmit="sendMessage(event)" class="flex items-end gap-2">
|
||||
<div class="flex-1 bg-slate-50 border border-slate-300 rounded-lg p-1 focus-within:border-indigo-600 focus-within:bg-white transition">
|
||||
<textarea id="user-input" rows="1" autocomplete="off" autocorrect="off" spellcheck="false"
|
||||
placeholder="Команда или вопрос (Shift+Enter — новая строка, ↑/↓ — история)..."
|
||||
class="w-full bg-transparent text-slate-800 px-3 py-1.5 text-sm focus:outline-none resize-none overflow-y-auto max-h-[96px] leading-relaxed fade-scroll-top no-scrollbar"></textarea>
|
||||
</div>
|
||||
<button type="submit" id="send-btn" class="bg-indigo-600 hover:bg-indigo-700 text-white font-medium px-5 h-[42px] rounded-lg text-sm transition flex items-center gap-2 shrink-0">
|
||||
<span>Отправить</span>
|
||||
<i class="fa-solid fa-paper-plane text-xs"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Выезжающая панель (Drawer) -->
|
||||
<div id="drawer-backdrop" onclick="toggleDrawer()" class="fixed inset-0 bg-slate-900/30 backdrop-blur-sm hidden transition-opacity z-40"></div>
|
||||
|
||||
<aside id="task-drawer" class="fixed right-0 top-0 h-full w-[420px] bg-white border-l border-slate-200 shadow-2xl transform translate-x-full transition-transform duration-300 ease-in-out z-50 flex flex-col">
|
||||
<div class="p-4 border-b border-slate-200 flex justify-between items-center bg-slate-50">
|
||||
<h2 class="font-bold text-slate-800 flex items-center gap-2 text-sm">
|
||||
<i class="fa-solid fa-list-check text-indigo-600"></i> Реестр задач
|
||||
</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<button onclick="loadTasks()" class="text-xs text-slate-500 hover:text-indigo-600 transition p-1" title="Обновить">
|
||||
<i class="fa-solid fa-rotate-right"></i>
|
||||
</button>
|
||||
<button onclick="toggleDrawer()" class="text-slate-400 hover:text-slate-700 transition p-1">
|
||||
<i class="fa-solid fa-xmark text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Фильтры задач -->
|
||||
<div class="flex border-b border-slate-200 bg-white px-2 pt-2 text-xs font-medium text-slate-500 gap-1">
|
||||
<button onclick="setFilter('ALL')" id="filter-ALL" class="px-3 py-1.5 rounded-t-lg border-b-2 border-indigo-600 text-indigo-600 font-bold">Все</button>
|
||||
<button onclick="setFilter('IN_PROGRESS')" id="filter-IN_PROGRESS" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent hover:text-slate-700">В работе</button>
|
||||
<button onclick="setFilter('BACKLOG')" id="filter-BACKLOG" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent hover:text-slate-700">Бэклог</button>
|
||||
<button onclick="setFilter('COMPLETED')" id="filter-COMPLETED" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent hover:text-slate-700">Завершено</button>
|
||||
</div>
|
||||
|
||||
<div id="tasks-container" class="flex-1 overflow-y-auto p-4 space-y-3 bg-slate-50/50">
|
||||
<div class="text-center text-slate-400 py-8 text-xs">Загрузка задач...</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
EOF
|
||||
|
||||
# 5. Фиксируем изменения фронтенда в Git
|
||||
git add static/
|
||||
git commit -m "refactor: декомпозиция фронтенда index.html (вынос static/css/styles.css и static/js/app.js)"
|
||||
git push origin feature/llm-refactoring
|
||||
+1
-1
@@ -2,4 +2,4 @@ home = /usr/bin
|
||||
include-system-site-packages = true
|
||||
version = 3.12.3
|
||||
executable = /usr/bin/python3.12
|
||||
command = /usr/bin/python3 -m venv --system-site-packages /home/puh/scud_context_api/venv
|
||||
command = /home/puh/scud_orion_ai_v2/venv/bin/python3 -m venv --system-site-packages /home/puh/scud_context_api/venv
|
||||
|
||||
Reference in New Issue
Block a user