fixed injection watcher
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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("/")
|
||||||
|
|
||||||
def search_exercises(self, query: str, limit: int = 8) -> list[dict[str, Any]]:
|
@staticmethod
|
||||||
with httpx.Client(timeout=20.0) as client:
|
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(
|
response = client.get(
|
||||||
f"{self.base_url}/exercise/search/",
|
f"{self.base_url}/exerciseinfo/",
|
||||||
params={"term": query, "language": "ru"},
|
params={
|
||||||
|
"name__search": query,
|
||||||
|
"languagecode": languagecode,
|
||||||
|
"limit": limit,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
return response.json().get("results") or []
|
||||||
sug = data.get("suggestions", data) if isinstance(data, dict) else []
|
|
||||||
if isinstance(sug, dict):
|
def search_exercises(self, query: str, limit: int = 8) -> list[dict[str, Any]]:
|
||||||
results = sug.get("results", [])
|
query = query.strip()
|
||||||
elif isinstance(sug, list):
|
if not query:
|
||||||
results = sug
|
return []
|
||||||
else:
|
|
||||||
results = []
|
with httpx.Client(timeout=20.0) as client:
|
||||||
|
results = self._fetch_exerciseinfo(
|
||||||
|
client, query=query, languagecode="ru", limit=limit
|
||||||
|
)
|
||||||
|
if not results:
|
||||||
|
results = self._fetch_exerciseinfo(
|
||||||
|
client, query=query, languagecode="en", limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
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]
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user