fixed injection watcher

This commit is contained in:
2026-06-11 08:11:51 +03:00
parent 06e09cd728
commit 481b93e84a
5 changed files with 131 additions and 37 deletions
+51 -3
View File
@@ -1,6 +1,7 @@
import asyncio import asyncio
import json import json
import logging import logging
import time
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from typing import Any from typing import Any
@@ -179,14 +180,21 @@ class ChatService:
self._save_message(session_id, "user", user_text) self._save_message(session_id, "user", user_text)
yield self._sse("status", {"phase": "preparing"}) yield self._sse("status", {"phase": "preparing"})
t0 = time.monotonic()
messages = await asyncio.to_thread(_build_messages_for_session, session_id) messages = await asyncio.to_thread(_build_messages_for_session, session_id)
prepare_sec = time.monotonic() - t0
if not messages: if not messages:
yield self._sse("error", {"message": "Session not found"}) yield self._sse("error", {"message": "Session not found"})
return return
yield self._sse("status", {"phase": "generating"}) yield self._sse("status", {"phase": "generating"})
streamed_reply_parts: list[str] = [] streamed_reply_parts: list[str] = []
all_tool_notices: list[str] = []
tools_executed = 0
tool_round = 0
for _ in range(MAX_TOOL_ROUNDS): for _ in range(MAX_TOOL_ROUNDS):
tool_round += 1
t_round = time.monotonic()
content_parts: list[str] = [] content_parts: list[str] = []
tool_calls: list[dict[str, Any]] = [] tool_calls: list[dict[str, Any]] = []
reasoning = "" reasoning = ""
@@ -201,11 +209,29 @@ class ChatService:
if event.get("reasoning_details"): if event.get("reasoning_details"):
reasoning_details = event["reasoning_details"] reasoning_details = event["reasoning_details"]
elif event["type"] == "error": elif event["type"] == "error":
logger.warning(
"chat session=%s llm_error round=%d prepare=%.2fs: %s",
session_id,
tool_round,
prepare_sec,
event.get("content"),
)
yield self._sse("error", {"message": event.get("content", "LLM error")}) yield self._sse("error", {"message": event.get("content", "LLM error")})
return return
elif event["type"] == "tool_calls": elif event["type"] == "tool_calls":
tool_calls = event["tool_calls"] tool_calls = event["tool_calls"]
logger.info(
"chat session=%s round=%d prepare=%.2fs llm=%.2fs "
"content_len=%d tool_calls=%d",
session_id,
tool_round,
prepare_sec,
time.monotonic() - t_round,
len("".join(content_parts)),
len(tool_calls),
)
if tool_calls: if tool_calls:
round_text = "".join(content_parts) round_text = "".join(content_parts)
if round_text.strip(): if round_text.strip():
@@ -241,6 +267,7 @@ class ChatService:
result = await execute_tool( result = await execute_tool(
self.db, fn["name"], args, session_id=session_id self.db, fn["name"], args, session_id=session_id
) )
tools_executed += 1
tool_message = { tool_message = {
"role": "tool", "role": "tool",
"tool_call_id": tool_call["id"], "tool_call_id": tool_call["id"],
@@ -253,6 +280,7 @@ class ChatService:
if notice: if notice:
self._save_message(session_id, "notice", notice) self._save_message(session_id, "notice", notice)
round_notices.append(notice) round_notices.append(notice)
all_tool_notices.append(notice)
if fn["name"] in POMODORO_TOOL_NAMES: if fn["name"] in POMODORO_TOOL_NAMES:
yield self._sse( yield self._sse(
@@ -270,14 +298,27 @@ class ChatService:
final_content = "".join(streamed_reply_parts).strip() final_content = "".join(streamed_reply_parts).strip()
if not final_content and reasoning: if not final_content and reasoning:
final_content = reasoning.strip() final_content = reasoning.strip()
if not final_content and all_tool_notices:
# Notices уже ушли в SSE event: notice; здесь только финальный текст в БД.
final_content = "\n\n".join(all_tool_notices)
if not final_content and tools_executed:
retry = await self.llm.complete(messages, tools=None, temperature=0.4)
final_content = (retry.get("content") or "").strip()
if final_content:
yield self._sse("token", {"content": final_content})
if not final_content: if not final_content:
logger.warning(
"chat session=%s empty_reply tools=%d rounds=%d",
session_id,
tools_executed,
tool_round,
)
yield self._sse( yield self._sse(
"error", "error",
{ {
"message": ( "message": (
"Модель не вернула текст. Для deepseek-v4-pro: " "Модель не вернула текст после выполнения команд. "
"OPENROUTER_TOOLS_ENABLED=true и OPENROUTER_REASONING_EFFORT=none. " "Проверь OPENROUTER_MODEL и OPENROUTER_REASONING_EFFORT=none."
"Для памяти: MEMORY_EXTRACT_MODEL=deepseek/deepseek-chat."
), ),
}, },
) )
@@ -285,6 +326,13 @@ class ChatService:
self._save_message(session_id, "assistant", final_content) self._save_message(session_id, "assistant", final_content)
logger.info(
"chat session=%s done tools=%d reply_len=%d total=%.2fs",
session_id,
tools_executed,
len(final_content),
time.monotonic() - t0,
)
yield self._sse("done", {}) yield self._sse("done", {})
if get_settings().memory_auto_extract: if get_settings().memory_auto_extract:
asyncio.create_task( asyncio.create_task(
+55 -31
View File
@@ -4,45 +4,69 @@ import httpx
from app.config import get_settings from app.config import get_settings
# wger language ids (https://wger.de/api/v2/language/)
_LANG_RU = 5
_LANG_EN = 2
class WgerClient: class WgerClient:
def __init__(self) -> None: def __init__(self) -> None:
settings = get_settings() settings = get_settings()
self.base_url = settings.wger_base_url.rstrip("/") self.base_url = settings.wger_base_url.rstrip("/")
@staticmethod
def _pick_name(item: dict[str, Any]) -> str:
translations = item.get("translations") or []
for lang_id in (_LANG_RU, _LANG_EN):
for tr in translations:
if tr.get("language") == lang_id and tr.get("name"):
return str(tr["name"])
for tr in translations:
if tr.get("name"):
return str(tr["name"])
return f"#{item.get('id')}"
def _fetch_exerciseinfo(
self,
client: httpx.Client,
*,
query: str,
languagecode: str,
limit: int,
) -> list[dict[str, Any]]:
response = client.get(
f"{self.base_url}/exerciseinfo/",
params={
"name__search": query,
"languagecode": languagecode,
"limit": limit,
},
)
response.raise_for_status()
return response.json().get("results") or []
def search_exercises(self, query: str, limit: int = 8) -> list[dict[str, Any]]: def search_exercises(self, query: str, limit: int = 8) -> list[dict[str, Any]]:
query = query.strip()
if not query:
return []
with httpx.Client(timeout=20.0) as client: with httpx.Client(timeout=20.0) as client:
response = client.get( results = self._fetch_exerciseinfo(
f"{self.base_url}/exercise/search/", client, query=query, languagecode="ru", limit=limit
params={"term": query, "language": "ru"},
) )
response.raise_for_status() if not results:
data = response.json() results = self._fetch_exerciseinfo(
sug = data.get("suggestions", data) if isinstance(data, dict) else [] client, query=query, languagecode="en", limit=limit
if isinstance(sug, dict): )
results = sug.get("results", [])
elif isinstance(sug, list):
results = sug
else:
results = []
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
for item in results[:limit]: for item in results[:limit]:
if isinstance(item, dict): category = item.get("category") or {}
name = item.get("value") or item.get("name") or str(item) out.append(
out.append({"name": name, "data": item}) {
elif isinstance(item, str): "id": item.get("id"),
out.append({"name": item}) "name": self._pick_name(item),
if out: "category": category.get("name") if isinstance(category, dict) else category,
return out }
response2 = client.get(
f"{self.base_url}/exerciseinfo/",
params={"language": 2, "limit": limit},
) )
response2.raise_for_status() return out
for item in (response2.json().get("results") or [])[:limit]:
name = item.get("name") or f"#{item.get('id')}"
if query.lower() in name.lower():
out.append({"id": item.get("id"), "name": name, "category": item.get("category")})
return out[:limit]
+4 -1
View File
@@ -41,7 +41,10 @@
.app-main { .app-main {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow: hidden; overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
+8
View File
@@ -179,6 +179,14 @@
font-size: 0.92rem; font-size: 0.92rem;
} }
.message-error {
align-self: center;
max-width: 92%;
background: #2a1a1a;
border: 1px solid #5a2a2a;
color: #f0b0b0;
}
.message-role { .message-role {
font-size: 0.75rem; font-size: 0.75rem;
color: #8b95a5; color: #8b95a5;
+13 -2
View File
@@ -42,6 +42,7 @@ export default function Chat() {
"thinking", "thinking",
); );
const [liveNotices, setLiveNotices] = useState<string[]>([]); const [liveNotices, setLiveNotices] = useState<string[]>([]);
const [chatError, setChatError] = useState<string | null>(null);
const messagesRef = useRef<HTMLDivElement>(null); const messagesRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
const scrollRafRef = useRef<number | null>(null); const scrollRafRef = useRef<number | null>(null);
@@ -93,7 +94,7 @@ export default function Chat() {
cancelAnimationFrame(scrollRafRef.current); cancelAnimationFrame(scrollRafRef.current);
} }
}; };
}, [messages, streaming, liveNotices, loading, scrollToBottom]); }, [messages, streaming, liveNotices, loading, chatError, scrollToBottom]);
const dismissKeyboard = useCallback(() => { const dismissKeyboard = useCallback(() => {
inputRef.current?.blur(); inputRef.current?.blur();
@@ -151,6 +152,7 @@ export default function Chat() {
setStreaming(""); setStreaming("");
setPendingPhase("thinking"); setPendingPhase("thinking");
setLiveNotices([]); setLiveNotices([]);
setChatError(null);
const tempUser: ChatMessage = { const tempUser: ChatMessage = {
id: Date.now(), id: Date.now(),
@@ -187,6 +189,7 @@ export default function Chat() {
if (chunk.event === "done") { if (chunk.event === "done") {
setStreaming(""); setStreaming("");
setLiveNotices([]); setLiveNotices([]);
setChatError(null);
setLoading(false); setLoading(false);
if (assistantText.trim()) { if (assistantText.trim()) {
setMessages((prev) => [ setMessages((prev) => [
@@ -208,8 +211,9 @@ export default function Chat() {
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
const message = err instanceof Error ? err.message : "Ошибка чата";
setChatError(message);
setStreaming(""); setStreaming("");
setLiveNotices([]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -310,6 +314,13 @@ export default function Chat() {
</div> </div>
</div> </div>
)} )}
{chatError && (
<div className="message message-error" role="alert">
<div className="message-role">ошибка</div>
<div className="message-content">{chatError}</div>
</div>
)}
<div className="messages-bottom-anchor" aria-hidden="true" /> <div className="messages-bottom-anchor" aria-hidden="true" />
</div> </div>