tg-webui 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tg_webui/__init__.py +1 -0
- tg_webui/agent/__init__.py +0 -0
- tg_webui/agent/forms.py +18 -0
- tg_webui/agent/handlers.py +506 -0
- tg_webui/agent/responses.py +51 -0
- tg_webui/app.py +542 -0
- tg_webui/assembly.py +206 -0
- tg_webui/bootstrap.py +41 -0
- tg_webui/channels/__init__.py +0 -0
- tg_webui/channels/forms.py +17 -0
- tg_webui/channels/handlers.py +166 -0
- tg_webui/channels/responses.py +45 -0
- tg_webui/container.py +29 -0
- tg_webui/csrf.py +134 -0
- tg_webui/deps.py +393 -0
- tg_webui/dialogs/__init__.py +1 -0
- tg_webui/dialogs/forms.py +221 -0
- tg_webui/dialogs/handlers.py +555 -0
- tg_webui/dialogs/responses.py +70 -0
- tg_webui/embedded_worker.py +209 -0
- tg_webui/filter/__init__.py +0 -0
- tg_webui/filter/forms.py +81 -0
- tg_webui/filter/handlers.py +270 -0
- tg_webui/filter/responses.py +48 -0
- tg_webui/images/__init__.py +0 -0
- tg_webui/images/forms.py +36 -0
- tg_webui/images/handlers.py +110 -0
- tg_webui/images/responses.py +34 -0
- tg_webui/panel_auth.py +93 -0
- tg_webui/paths.py +7 -0
- tg_webui/photo_loader/__init__.py +2 -0
- tg_webui/photo_loader/forms.py +136 -0
- tg_webui/photo_loader/handlers.py +509 -0
- tg_webui/photo_loader/responses.py +42 -0
- tg_webui/pipelines/__init__.py +0 -0
- tg_webui/pipelines/forms.py +206 -0
- tg_webui/pipelines/handlers.py +1051 -0
- tg_webui/pipelines/responses.py +85 -0
- tg_webui/py.typed +0 -0
- tg_webui/query_params.py +27 -0
- tg_webui/redirects.py +18 -0
- tg_webui/responses.py +71 -0
- tg_webui/routes/__init__.py +0 -0
- tg_webui/routes/accounts.py +221 -0
- tg_webui/routes/agent.py +105 -0
- tg_webui/routes/analytics.py +585 -0
- tg_webui/routes/auth.py +391 -0
- tg_webui/routes/calendar.py +155 -0
- tg_webui/routes/channel_collection.py +198 -0
- tg_webui/routes/channel_renames.py +194 -0
- tg_webui/routes/channels.py +100 -0
- tg_webui/routes/dashboard.py +106 -0
- tg_webui/routes/debug.py +157 -0
- tg_webui/routes/dialogs.py +234 -0
- tg_webui/routes/dms.py +139 -0
- tg_webui/routes/export.py +87 -0
- tg_webui/routes/filter.py +86 -0
- tg_webui/routes/images.py +49 -0
- tg_webui/routes/import_channels.py +87 -0
- tg_webui/routes/jobs.py +257 -0
- tg_webui/routes/moderation.py +177 -0
- tg_webui/routes/photo_loader.py +217 -0
- tg_webui/routes/pipelines.py +374 -0
- tg_webui/routes/plugin_tools.py +18 -0
- tg_webui/routes/rss.py +144 -0
- tg_webui/routes/scheduler.py +167 -0
- tg_webui/routes/search.py +115 -0
- tg_webui/routes/search_queries.py +91 -0
- tg_webui/routes/settings.py +291 -0
- tg_webui/routes/telegram_commands.py +48 -0
- tg_webui/scheduler/__init__.py +1 -0
- tg_webui/scheduler/commands.py +30 -0
- tg_webui/scheduler/context.py +636 -0
- tg_webui/scheduler/forms.py +52 -0
- tg_webui/scheduler/handlers.py +301 -0
- tg_webui/scheduler/responses.py +87 -0
- tg_webui/schemas/__init__.py +30 -0
- tg_webui/schemas/accounts.py +36 -0
- tg_webui/schemas/agent.py +26 -0
- tg_webui/schemas/analytics.py +155 -0
- tg_webui/schemas/calendar.py +32 -0
- tg_webui/schemas/common.py +31 -0
- tg_webui/schemas/dialogs.py +71 -0
- tg_webui/schemas/pipelines.py +110 -0
- tg_webui/search/__init__.py +1 -0
- tg_webui/search/forms.py +31 -0
- tg_webui/search/handlers.py +522 -0
- tg_webui/search/responses.py +42 -0
- tg_webui/search_queries/__init__.py +0 -0
- tg_webui/search_queries/forms.py +48 -0
- tg_webui/search_queries/handlers.py +92 -0
- tg_webui/search_queries/responses.py +37 -0
- tg_webui/session.py +60 -0
- tg_webui/settings/__init__.py +2 -0
- tg_webui/settings/forms.py +251 -0
- tg_webui/settings/handlers.py +1322 -0
- tg_webui/settings/responses.py +58 -0
- tg_webui/snapshot_refresher.py +82 -0
- tg_webui/static/_busy_button.js +26 -0
- tg_webui/static/_theme_init.js +1 -0
- tg_webui/static/js/app.js +393 -0
- tg_webui/static/js/nav-progress.js +101 -0
- tg_webui/static/js/searchable-picker.js +275 -0
- tg_webui/static/settings.js +491 -0
- tg_webui/static/style.css +592 -0
- tg_webui/template_globals.py +321 -0
- tg_webui/templates/_debug_errors.html +34 -0
- tg_webui/templates/_debug_logs.html +10 -0
- tg_webui/templates/_dry_run_results.html +52 -0
- tg_webui/templates/_macros.html +100 -0
- tg_webui/templates/_macros_ui.html +86 -0
- tg_webui/templates/_timing_rows.html +13 -0
- tg_webui/templates/agent/_threads.html +120 -0
- tg_webui/templates/agent.html +1358 -0
- tg_webui/templates/analytics/_channel_selector.html +8 -0
- tg_webui/templates/analytics/_content_pipelines.html +43 -0
- tg_webui/templates/analytics/_content_summary.html +32 -0
- tg_webui/templates/analytics/_engagement.html +19 -0
- tg_webui/templates/analytics/_hourly.html +19 -0
- tg_webui/templates/analytics/_rating_verdict.html +28 -0
- tg_webui/templates/analytics/_top_messages.html +62 -0
- tg_webui/templates/analytics/_trends_channels.html +25 -0
- tg_webui/templates/analytics/_trends_emojis.html +12 -0
- tg_webui/templates/analytics/_trends_topics.html +16 -0
- tg_webui/templates/analytics/channel_ratings.html +90 -0
- tg_webui/templates/analytics/channels.html +422 -0
- tg_webui/templates/analytics/content.html +36 -0
- tg_webui/templates/analytics/trends.html +49 -0
- tg_webui/templates/analytics.html +63 -0
- tg_webui/templates/base.html +104 -0
- tg_webui/templates/calendar/_grid.html +64 -0
- tg_webui/templates/calendar/_stats.html +18 -0
- tg_webui/templates/calendar/_upcoming.html +33 -0
- tg_webui/templates/calendar.html +57 -0
- tg_webui/templates/channel_renames.html +61 -0
- tg_webui/templates/channel_review.html +56 -0
- tg_webui/templates/channels/_list.html +120 -0
- tg_webui/templates/channels.html +152 -0
- tg_webui/templates/dashboard/_overview.html +71 -0
- tg_webui/templates/dashboard.html +11 -0
- tg_webui/templates/debug.html +34 -0
- tg_webui/templates/debug_timing.html +30 -0
- tg_webui/templates/dialogs/_history.html +205 -0
- tg_webui/templates/dialogs/_list.html +176 -0
- tg_webui/templates/dialogs.html +122 -0
- tg_webui/templates/dialogs_create_channel.html +57 -0
- tg_webui/templates/dms.html +106 -0
- tg_webui/templates/error.html +20 -0
- tg_webui/templates/filter_manage.html +13 -0
- tg_webui/templates/filter_manage_content.html +245 -0
- tg_webui/templates/images.html +210 -0
- tg_webui/templates/import_channels.html +72 -0
- tg_webui/templates/jobs.html +23 -0
- tg_webui/templates/jobs_table.html +216 -0
- tg_webui/templates/login.html +195 -0
- tg_webui/templates/moderation/view.html +123 -0
- tg_webui/templates/moderation.html +15 -0
- tg_webui/templates/moderation_content.html +111 -0
- tg_webui/templates/photo_loader.html +13 -0
- tg_webui/templates/photo_loader_batch_row.html +29 -0
- tg_webui/templates/photo_loader_content.html +597 -0
- tg_webui/templates/pipelines/create.html +892 -0
- tg_webui/templates/pipelines/edit.html +292 -0
- tg_webui/templates/pipelines/generate.html +124 -0
- tg_webui/templates/pipelines/templates.html +187 -0
- tg_webui/templates/pipelines.html +13 -0
- tg_webui/templates/pipelines_content.html +316 -0
- tg_webui/templates/qr_login.html +104 -0
- tg_webui/templates/scheduler/_health.html +93 -0
- tg_webui/templates/scheduler/_jobs.html +114 -0
- tg_webui/templates/scheduler.html +90 -0
- tg_webui/templates/search.html +181 -0
- tg_webui/templates/search_queries.html +269 -0
- tg_webui/templates/search_results.html +121 -0
- tg_webui/templates/settings/_accounts.html +153 -0
- tg_webui/templates/settings/_credentials.html +41 -0
- tg_webui/templates/settings/_devmode.html +269 -0
- tg_webui/templates/settings/_filters.html +36 -0
- tg_webui/templates/settings/_image_providers.html +230 -0
- tg_webui/templates/settings/_notifications.html +52 -0
- tg_webui/templates/settings/_plugins.html +18 -0
- tg_webui/templates/settings/_scheduler.html +17 -0
- tg_webui/templates/settings/_semantic.html +96 -0
- tg_webui/templates/settings/_tool_permissions.html +208 -0
- tg_webui/templates/settings/_translation.html +79 -0
- tg_webui/templates/settings.html +83 -0
- tg_webui/templates/web_login.html +39 -0
- tg_webui-0.0.1.dist-info/METADATA +52 -0
- tg_webui-0.0.1.dist-info/RECORD +192 -0
- tg_webui-0.0.1.dist-info/WHEEL +5 -0
- tg_webui-0.0.1.dist-info/licenses/LICENSE +21 -0
- tg_webui-0.0.1.dist-info/top_level.txt +1 -0
tg_webui/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Legacy web panel of tg-agent (FastAPI + Jinja2), an optional package since #1720 wave 3."""
|
|
File without changes
|
tg_webui/agent/forms.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Request body parsing for the agent web domain."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from tg_agent.agent.models import CLAUDE_MODEL_IDS
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ChatRequest(BaseModel):
|
|
11
|
+
message: str
|
|
12
|
+
model: str | None = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def select_model(raw_model: object) -> str | None:
|
|
16
|
+
"""Return the requested model id only if it is a known Claude model, else None."""
|
|
17
|
+
model = (str(raw_model) if raw_model is not None else "").strip()
|
|
18
|
+
return model if model in CLAUDE_MODEL_IDS else None
|
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
"""Application orchestration for the agent web domain."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import sqlite3
|
|
9
|
+
from typing import TYPE_CHECKING, cast
|
|
10
|
+
|
|
11
|
+
from fastapi import HTTPException, Request
|
|
12
|
+
|
|
13
|
+
from tg_agent.agent.models import CLAUDE_MODELS
|
|
14
|
+
from tg_agent.utils.json import safe_json_dumps
|
|
15
|
+
from tg_webui import deps
|
|
16
|
+
from tg_webui.agent.forms import select_model
|
|
17
|
+
from tg_webui.agent.responses import AgentJson, AgentRedirect, AgentStream, AgentTemplate
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from tg_agent.agent.manager import AgentManager
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
# How often the agent SSE wrapper wakes up while the backend has not emitted a
|
|
25
|
+
# chunk yet. This is a keepalive cadence, not a cancellation deadline. The hard
|
|
26
|
+
# cancellation deadline is config.agent.total_timeout (shared with the backends).
|
|
27
|
+
_SSE_KEEPALIVE_INTERVAL = 15.0
|
|
28
|
+
|
|
29
|
+
_SAVE_FAILED_WARNING = (
|
|
30
|
+
"❗ Ответ не удалось сохранить — он пропадёт при перезагрузке страницы."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def _json_object_body(request: Request) -> dict:
|
|
35
|
+
"""Return the request body as a JSON object, or raise HTTP 400."""
|
|
36
|
+
try:
|
|
37
|
+
body = await request.json()
|
|
38
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
39
|
+
raise HTTPException(status_code=400, detail="Request body must be valid JSON") from None
|
|
40
|
+
if not isinstance(body, dict):
|
|
41
|
+
raise HTTPException(status_code=400, detail="Request body must be a JSON object")
|
|
42
|
+
return body
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _coerce_int(value: object, field: str) -> int:
|
|
46
|
+
if isinstance(value, bool):
|
|
47
|
+
raise HTTPException(status_code=400, detail=f"{field} must be an integer")
|
|
48
|
+
if isinstance(value, int):
|
|
49
|
+
return value
|
|
50
|
+
if isinstance(value, str):
|
|
51
|
+
text = value.strip()
|
|
52
|
+
if text and (text.isdecimal() or (text[0] in "+-" and text[1:].isdecimal())):
|
|
53
|
+
return int(text)
|
|
54
|
+
raise HTTPException(status_code=400, detail=f"{field} must be an integer")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _agent_unavailable_copy(runtime_state: deps.AgentRuntimeState) -> tuple[str, str]:
|
|
58
|
+
if runtime_state.state == "starting":
|
|
59
|
+
return (
|
|
60
|
+
"Агент запускается.",
|
|
61
|
+
"Рабочий процесс запускает чат. Обновите страницу через несколько секунд.",
|
|
62
|
+
)
|
|
63
|
+
if runtime_state.state == "failed":
|
|
64
|
+
return (
|
|
65
|
+
"Агент не запустился.",
|
|
66
|
+
runtime_state.error or "Рабочий процесс не запустился. Проверьте логи сервера.",
|
|
67
|
+
)
|
|
68
|
+
return (
|
|
69
|
+
"Чат недоступен в web-процессе.",
|
|
70
|
+
"Чат работает только в рабочем процессе. "
|
|
71
|
+
"Запустите рядом с web-сервером команду `python -m tg_agent.main worker`.",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _agent_unavailable_result(runtime_state: deps.AgentRuntimeState) -> AgentJson:
|
|
76
|
+
_, detail = _agent_unavailable_copy(runtime_state)
|
|
77
|
+
headers = {"Retry-After": "3"} if runtime_state.state == "starting" else None
|
|
78
|
+
return AgentJson(
|
|
79
|
+
{"detail": detail, "runtime_state": runtime_state.state},
|
|
80
|
+
status_code=503,
|
|
81
|
+
headers=headers,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def _agent_status_dict(agent_manager: AgentManager | None) -> dict | None:
|
|
86
|
+
"""Probe the agent backend and map its runtime status to a template dict.
|
|
87
|
+
|
|
88
|
+
This is the heavy bit (`refresh_settings_cache` + per-backend availability
|
|
89
|
+
probes); kept out of the skeleton route so it only runs when the lazy
|
|
90
|
+
threads fragment loads (#949 / part of #756).
|
|
91
|
+
"""
|
|
92
|
+
if agent_manager is None:
|
|
93
|
+
return None
|
|
94
|
+
runtime_status = await agent_manager.get_runtime_status()
|
|
95
|
+
return {
|
|
96
|
+
"claude_available": runtime_status.claude_available,
|
|
97
|
+
"deepagents_available": runtime_status.deepagents_available,
|
|
98
|
+
"codex_available": runtime_status.codex_available,
|
|
99
|
+
"adk_available": runtime_status.adk_available,
|
|
100
|
+
"dev_mode_enabled": runtime_status.dev_mode_enabled,
|
|
101
|
+
"backend_override": runtime_status.backend_override,
|
|
102
|
+
"selected_backend": runtime_status.selected_backend,
|
|
103
|
+
"fallback_model": runtime_status.fallback_model,
|
|
104
|
+
"fallback_provider": runtime_status.fallback_provider,
|
|
105
|
+
"using_override": runtime_status.using_override,
|
|
106
|
+
"error": runtime_status.error,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def agent_page(request: Request, thread_id: int | None = None):
|
|
111
|
+
# Onboarding/redirect logic MUST stay synchronous — it decides which thread the
|
|
112
|
+
# page renders (a skeleton 200 would swallow the redirect, mirroring dashboard.py).
|
|
113
|
+
# The runtime-status probe (refresh_settings_cache + backend availability) is the
|
|
114
|
+
# heavy per-load cost; it loads lazily in the threads fragment (#949).
|
|
115
|
+
db = deps.get_db(request)
|
|
116
|
+
agent_runtime_state = deps.get_agent_runtime_state(request)
|
|
117
|
+
threads = await db.get_agent_threads()
|
|
118
|
+
agent_disabled_title = None
|
|
119
|
+
agent_disabled_reason = None
|
|
120
|
+
if agent_runtime_state.manager is None:
|
|
121
|
+
agent_disabled_title, agent_disabled_reason = _agent_unavailable_copy(agent_runtime_state)
|
|
122
|
+
|
|
123
|
+
messages = []
|
|
124
|
+
active_thread = None
|
|
125
|
+
|
|
126
|
+
if thread_id is not None:
|
|
127
|
+
active_thread = await db.get_agent_thread(thread_id)
|
|
128
|
+
if active_thread is None and threads:
|
|
129
|
+
logger.debug("Thread %s not found, redirect to first thread", thread_id)
|
|
130
|
+
return AgentRedirect(f"/agent?thread_id={threads[0]['id']}")
|
|
131
|
+
if active_thread is not None:
|
|
132
|
+
messages = await db.get_agent_messages(thread_id)
|
|
133
|
+
elif threads:
|
|
134
|
+
logger.debug("No thread_id param, redirect to first thread %s", threads[0]["id"])
|
|
135
|
+
return AgentRedirect(f"/agent?thread_id={threads[0]['id']}")
|
|
136
|
+
else:
|
|
137
|
+
thread_id = await db.create_agent_thread("Новый тред")
|
|
138
|
+
logger.debug("No threads exist, auto-created thread %s", thread_id)
|
|
139
|
+
return AgentRedirect(f"/agent?thread_id={thread_id}")
|
|
140
|
+
|
|
141
|
+
return AgentTemplate(
|
|
142
|
+
"agent.html",
|
|
143
|
+
{
|
|
144
|
+
"active_thread": active_thread,
|
|
145
|
+
"messages": messages,
|
|
146
|
+
"agent_runtime_state": agent_runtime_state.state,
|
|
147
|
+
"agent_disabled_title": agent_disabled_title,
|
|
148
|
+
"agent_disabled_reason": agent_disabled_reason,
|
|
149
|
+
},
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async def agent_threads_fragment(request: Request, thread_id: int | None = None) -> AgentTemplate:
|
|
154
|
+
"""Lazy fragment: thread list + runtime status panel + composer footer (#949).
|
|
155
|
+
|
|
156
|
+
Carries `active_thread` so the thread list can mark the open thread and the
|
|
157
|
+
composer footer (Claude model select vs per-backend hint) renders for the
|
|
158
|
+
right backend — the picker is Claude-only; codex/adk/deepagents get a hint
|
|
159
|
+
instead of a dead Claude dropdown (#1002). The skeleton already resolved the
|
|
160
|
+
active thread via redirect, so the fragment trusts the `thread_id` query
|
|
161
|
+
param instead of re-running that logic.
|
|
162
|
+
"""
|
|
163
|
+
db = deps.get_db(request)
|
|
164
|
+
agent_manager = deps.get_agent_manager(request)
|
|
165
|
+
threads = await db.get_agent_threads()
|
|
166
|
+
active_thread = await db.get_agent_thread(thread_id) if thread_id is not None else None
|
|
167
|
+
agent_status = await _agent_status_dict(agent_manager)
|
|
168
|
+
return AgentTemplate(
|
|
169
|
+
"agent/_threads.html",
|
|
170
|
+
{
|
|
171
|
+
"threads": threads,
|
|
172
|
+
"active_thread": active_thread,
|
|
173
|
+
"agent_status": agent_status,
|
|
174
|
+
"model_options": CLAUDE_MODELS,
|
|
175
|
+
},
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
async def create_thread(request: Request) -> AgentRedirect:
|
|
180
|
+
db = deps.get_db(request)
|
|
181
|
+
thread_id = await db.create_agent_thread("Новый тред")
|
|
182
|
+
return AgentRedirect(f"/agent?thread_id={thread_id}")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
async def delete_thread(request: Request, thread_id: int) -> AgentJson:
|
|
186
|
+
db = deps.get_db(request)
|
|
187
|
+
agent_manager = deps.get_agent_manager(request)
|
|
188
|
+
cancelled = False
|
|
189
|
+
if agent_manager is not None:
|
|
190
|
+
cancelled = await agent_manager.cancel_stream(thread_id, wait_timeout=5.0)
|
|
191
|
+
await db.delete_agent_thread(thread_id)
|
|
192
|
+
if agent_manager is not None and agent_manager.permission_gate is not None:
|
|
193
|
+
session_id = request.cookies.get("session", "web")
|
|
194
|
+
agent_manager.permission_gate.clear_thread(session_id, thread_id)
|
|
195
|
+
agent_manager.permission_gate.clear_session(session_id)
|
|
196
|
+
return AgentJson({"ok": True, "cancelled": cancelled})
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def rename_thread(request: Request, thread_id: int) -> AgentJson:
|
|
200
|
+
body = await _json_object_body(request)
|
|
201
|
+
title_raw = body.get("title")
|
|
202
|
+
title = title_raw.strip() if isinstance(title_raw, str) else ""
|
|
203
|
+
if not title:
|
|
204
|
+
raise HTTPException(status_code=400, detail="Title cannot be empty")
|
|
205
|
+
db = deps.get_db(request)
|
|
206
|
+
await db.rename_agent_thread(thread_id, title[:100])
|
|
207
|
+
return AgentJson({"ok": True})
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def get_channels_json(request: Request) -> AgentJson:
|
|
211
|
+
db = deps.get_db(request)
|
|
212
|
+
channels = await db.repos.channels.get_channels(active_only=True, include_filtered=False)
|
|
213
|
+
return AgentJson(
|
|
214
|
+
[
|
|
215
|
+
{
|
|
216
|
+
"id": ch.channel_id,
|
|
217
|
+
"title": ch.title or str(ch.channel_id),
|
|
218
|
+
"channel_type": ch.channel_type,
|
|
219
|
+
}
|
|
220
|
+
for ch in channels
|
|
221
|
+
]
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
async def get_forum_topics(request: Request, channel_id: int) -> AgentJson:
|
|
226
|
+
db = deps.get_db(request)
|
|
227
|
+
cached = await db.get_forum_topics(channel_id)
|
|
228
|
+
if cached:
|
|
229
|
+
return AgentJson(cached)
|
|
230
|
+
command_id = await deps.telegram_command_service(request).enqueue(
|
|
231
|
+
"agent.forum_topics_refresh",
|
|
232
|
+
payload={"channel_id": channel_id},
|
|
233
|
+
requested_by="web:agent",
|
|
234
|
+
)
|
|
235
|
+
return AgentJson({"status": "queued", "command_id": command_id}, status_code=202)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
async def inject_context(request: Request, thread_id: int) -> AgentJson:
|
|
239
|
+
data = await _json_object_body(request)
|
|
240
|
+
channel_id_raw = data.get("channel_id")
|
|
241
|
+
if not channel_id_raw:
|
|
242
|
+
raise HTTPException(status_code=400, detail="channel_id is required")
|
|
243
|
+
channel_id = _coerce_int(channel_id_raw, "channel_id")
|
|
244
|
+
raw_limit = _coerce_int(data.get("limit", 0), "limit")
|
|
245
|
+
limit = min(raw_limit, 10_000) if raw_limit > 0 else 10_000
|
|
246
|
+
topic_id_raw = data.get("topic_id")
|
|
247
|
+
if topic_id_raw is None or not str(topic_id_raw).strip():
|
|
248
|
+
topic_id = None
|
|
249
|
+
else:
|
|
250
|
+
topic_id = _coerce_int(topic_id_raw, "topic_id")
|
|
251
|
+
|
|
252
|
+
db = deps.get_db(request)
|
|
253
|
+
thread = await db.get_agent_thread(thread_id)
|
|
254
|
+
if thread is None:
|
|
255
|
+
raise HTTPException(status_code=404, detail="Thread not found")
|
|
256
|
+
|
|
257
|
+
messages, _ = await db.search_messages(
|
|
258
|
+
query="",
|
|
259
|
+
channel_id=channel_id,
|
|
260
|
+
limit=limit,
|
|
261
|
+
topic_id=topic_id,
|
|
262
|
+
)
|
|
263
|
+
ch = await db.get_channel_by_channel_id(channel_id)
|
|
264
|
+
title = cast(str, ch.title) if ch else str(channel_id)
|
|
265
|
+
|
|
266
|
+
topics = await db.get_forum_topics(channel_id)
|
|
267
|
+
topics_map = {t["id"]: t["title"] for t in topics}
|
|
268
|
+
|
|
269
|
+
from tg_agent.agent.context import format_context
|
|
270
|
+
|
|
271
|
+
content = format_context(messages, title, topic_id, topics_map)
|
|
272
|
+
|
|
273
|
+
await db.save_agent_message(thread_id=thread_id, role="user", content=content)
|
|
274
|
+
logger.info(
|
|
275
|
+
"Context loaded for thread %d: %d messages, %d chars",
|
|
276
|
+
thread_id,
|
|
277
|
+
len(messages),
|
|
278
|
+
len(content),
|
|
279
|
+
)
|
|
280
|
+
if len(content) > 200_000:
|
|
281
|
+
logger.warning(
|
|
282
|
+
"Large context for thread %d: %d chars (>200K) — may cause prompt overflow",
|
|
283
|
+
thread_id,
|
|
284
|
+
len(content),
|
|
285
|
+
)
|
|
286
|
+
return AgentJson({"content": content})
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
async def resolve_permission(request: Request, thread_id: int, request_id: str):
|
|
290
|
+
body = await _json_object_body(request)
|
|
291
|
+
choice = body.get("choice", "deny")
|
|
292
|
+
if choice not in ("once", "session", "deny"):
|
|
293
|
+
raise HTTPException(status_code=400, detail="Invalid choice")
|
|
294
|
+
agent_runtime_state = deps.get_agent_runtime_state(request)
|
|
295
|
+
agent_manager = agent_runtime_state.manager
|
|
296
|
+
if agent_manager is None:
|
|
297
|
+
return _agent_unavailable_result(agent_runtime_state)
|
|
298
|
+
ok = agent_manager.permission_gate.resolve(request_id, choice)
|
|
299
|
+
logger.info(
|
|
300
|
+
"Permission resolve from web: thread_id=%s request_id=%s choice=%s ok=%s",
|
|
301
|
+
thread_id,
|
|
302
|
+
request_id,
|
|
303
|
+
choice,
|
|
304
|
+
ok,
|
|
305
|
+
)
|
|
306
|
+
if not ok:
|
|
307
|
+
raise HTTPException(status_code=404, detail="Permission request not found or already resolved")
|
|
308
|
+
return AgentJson({"ok": True})
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
async def stop_chat(request: Request, thread_id: int) -> AgentJson:
|
|
312
|
+
db = deps.get_db(request)
|
|
313
|
+
agent_manager = deps.get_agent_manager(request)
|
|
314
|
+
cancelled = False
|
|
315
|
+
if agent_manager is not None:
|
|
316
|
+
cancelled = await agent_manager.cancel_stream(thread_id)
|
|
317
|
+
await db.delete_last_agent_exchange(thread_id)
|
|
318
|
+
return AgentJson({"ok": True, "cancelled": cancelled})
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
async def chat(request: Request, thread_id: int):
|
|
322
|
+
body = await _json_object_body(request)
|
|
323
|
+
message_raw = body.get("message")
|
|
324
|
+
message = message_raw.strip() if isinstance(message_raw, str) else ""
|
|
325
|
+
if not message:
|
|
326
|
+
raise HTTPException(status_code=400, detail="Message cannot be empty")
|
|
327
|
+
|
|
328
|
+
model = select_model(body.get("model"))
|
|
329
|
+
|
|
330
|
+
db = deps.get_db(request)
|
|
331
|
+
agent_runtime_state = deps.get_agent_runtime_state(request)
|
|
332
|
+
agent_manager = agent_runtime_state.manager
|
|
333
|
+
if agent_manager is None:
|
|
334
|
+
return _agent_unavailable_result(agent_runtime_state)
|
|
335
|
+
runtime_status = await agent_manager.get_runtime_status()
|
|
336
|
+
if runtime_status.selected_backend is None:
|
|
337
|
+
raise HTTPException(
|
|
338
|
+
status_code=503, detail=runtime_status.error or "Agent backend unavailable"
|
|
339
|
+
)
|
|
340
|
+
if runtime_status.using_override and runtime_status.error:
|
|
341
|
+
raise HTTPException(status_code=503, detail=runtime_status.error)
|
|
342
|
+
|
|
343
|
+
# Verify thread exists
|
|
344
|
+
thread = await db.get_agent_thread(thread_id)
|
|
345
|
+
if thread is None:
|
|
346
|
+
raise HTTPException(status_code=404, detail="Thread not found")
|
|
347
|
+
|
|
348
|
+
# Check prompt size before saving
|
|
349
|
+
estimated = await agent_manager.estimate_prompt_tokens(thread_id, message)
|
|
350
|
+
if estimated > 100_000:
|
|
351
|
+
raise HTTPException(
|
|
352
|
+
status_code=400,
|
|
353
|
+
detail="Контекст слишком длинный. Создайте новый тред.",
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
# Save user message
|
|
357
|
+
await db.save_agent_message(thread_id, "user", message)
|
|
358
|
+
|
|
359
|
+
# Auto-rename thread from first message
|
|
360
|
+
if thread["title"] == "Новый тред":
|
|
361
|
+
await db.rename_agent_thread(thread_id, message[:60])
|
|
362
|
+
|
|
363
|
+
# Use HTTP session cookie as session_id so per-user overrides are isolated
|
|
364
|
+
session_id = request.cookies.get("session", "web")
|
|
365
|
+
total_timeout_sec = request.app.state.config.agent.total_timeout
|
|
366
|
+
|
|
367
|
+
async def generate():
|
|
368
|
+
def _consume_abandoned_next_chunk(task: asyncio.Task) -> None:
|
|
369
|
+
try:
|
|
370
|
+
task.result()
|
|
371
|
+
except (asyncio.CancelledError, StopAsyncIteration):
|
|
372
|
+
pass
|
|
373
|
+
except Exception:
|
|
374
|
+
logger.debug("Abandoned agent stream chunk task failed", exc_info=True)
|
|
375
|
+
|
|
376
|
+
stream = agent_manager.chat_stream(
|
|
377
|
+
thread_id,
|
|
378
|
+
message,
|
|
379
|
+
model=model,
|
|
380
|
+
session_id=session_id,
|
|
381
|
+
interactive_permissions=True,
|
|
382
|
+
)
|
|
383
|
+
agen = stream.__aiter__()
|
|
384
|
+
waiting_for_permission = False
|
|
385
|
+
pending_chunk_task: asyncio.Task | None = None
|
|
386
|
+
loop = asyncio.get_running_loop()
|
|
387
|
+
stream_started_at = loop.time()
|
|
388
|
+
deadline = stream_started_at + total_timeout_sec
|
|
389
|
+
try:
|
|
390
|
+
while True:
|
|
391
|
+
try:
|
|
392
|
+
if waiting_for_permission:
|
|
393
|
+
chunk = await agen.__anext__()
|
|
394
|
+
else:
|
|
395
|
+
if pending_chunk_task is None:
|
|
396
|
+
pending_chunk_task = asyncio.create_task(agen.__anext__())
|
|
397
|
+
remaining_total = deadline - loop.time()
|
|
398
|
+
if remaining_total <= 0:
|
|
399
|
+
raise asyncio.TimeoutError
|
|
400
|
+
wait_timeout = min(_SSE_KEEPALIVE_INTERVAL, remaining_total)
|
|
401
|
+
done, _ = await asyncio.wait({pending_chunk_task}, timeout=wait_timeout)
|
|
402
|
+
if not done:
|
|
403
|
+
elapsed = int(loop.time() - stream_started_at)
|
|
404
|
+
status = {
|
|
405
|
+
"type": "status",
|
|
406
|
+
"text": f"Агент всё ещё работает... {elapsed}с",
|
|
407
|
+
}
|
|
408
|
+
yield f"data: {safe_json_dumps(status, ensure_ascii=False)}\n\n"
|
|
409
|
+
continue
|
|
410
|
+
chunk = pending_chunk_task.result()
|
|
411
|
+
pending_chunk_task = None
|
|
412
|
+
except StopAsyncIteration:
|
|
413
|
+
break
|
|
414
|
+
except asyncio.TimeoutError:
|
|
415
|
+
logger.warning(
|
|
416
|
+
"Agent SSE stream for thread %d exceeded total timeout %.0fs; aborting",
|
|
417
|
+
thread_id,
|
|
418
|
+
total_timeout_sec,
|
|
419
|
+
)
|
|
420
|
+
if pending_chunk_task is not None and not pending_chunk_task.done():
|
|
421
|
+
pending_chunk_task.cancel()
|
|
422
|
+
pending_chunk_task.add_done_callback(_consume_abandoned_next_chunk)
|
|
423
|
+
try:
|
|
424
|
+
await agent_manager.cancel_stream(thread_id, wait_timeout=5.0)
|
|
425
|
+
except Exception:
|
|
426
|
+
logger.debug("cancel_stream after SSE timeout failed", exc_info=True)
|
|
427
|
+
pause_note = (
|
|
428
|
+
"Фоновые задачи на live runtime были приостановлены на время запроса, "
|
|
429
|
+
"но инструмент всё равно не успел завершиться. "
|
|
430
|
+
if getattr(agent_manager, "_live_runtime_pause_gate", None) is not None
|
|
431
|
+
else ""
|
|
432
|
+
)
|
|
433
|
+
timeout_text = (
|
|
434
|
+
f"Ответ агента остановлен по таймауту {int(total_timeout_sec)} секунд. "
|
|
435
|
+
f"{pause_note}Повторите запрос точнее или меньшим объёмом."
|
|
436
|
+
)
|
|
437
|
+
try:
|
|
438
|
+
await db.save_agent_message(thread_id, "assistant", timeout_text)
|
|
439
|
+
except sqlite3.IntegrityError:
|
|
440
|
+
logger.debug("Thread %d deleted during timeout save; skipping", thread_id)
|
|
441
|
+
except Exception:
|
|
442
|
+
logger.exception(
|
|
443
|
+
"Failed to persist timeout assistant message for thread %d",
|
|
444
|
+
thread_id,
|
|
445
|
+
)
|
|
446
|
+
yield (
|
|
447
|
+
f"data: {safe_json_dumps({'error': timeout_text}, ensure_ascii=False)}\n\n"
|
|
448
|
+
)
|
|
449
|
+
break
|
|
450
|
+
save_failed = False
|
|
451
|
+
done_data: dict | None = None
|
|
452
|
+
try:
|
|
453
|
+
data_str = chunk.removeprefix("data: ").strip()
|
|
454
|
+
data = json.loads(data_str)
|
|
455
|
+
waiting_for_permission = data.get("type") == "permission_request"
|
|
456
|
+
if data.get("done") and data.get("full_text"):
|
|
457
|
+
done_data = data
|
|
458
|
+
try:
|
|
459
|
+
await db.save_agent_message(thread_id, "assistant", data["full_text"])
|
|
460
|
+
except sqlite3.IntegrityError:
|
|
461
|
+
logger.debug("Thread %d deleted during response; skipping save", thread_id)
|
|
462
|
+
except Exception:
|
|
463
|
+
# DB lock/disk/etc — the reply streamed fine but was not persisted.
|
|
464
|
+
# Surface it so the user knows it will be gone on reload, instead of
|
|
465
|
+
# silently dropping the assistant turn (#676).
|
|
466
|
+
logger.exception(
|
|
467
|
+
"Failed to persist assistant message for thread %d", thread_id
|
|
468
|
+
)
|
|
469
|
+
save_failed = True
|
|
470
|
+
elif data.get("error"):
|
|
471
|
+
try:
|
|
472
|
+
await db.delete_last_agent_exchange(thread_id)
|
|
473
|
+
except sqlite3.IntegrityError:
|
|
474
|
+
pass
|
|
475
|
+
except json.JSONDecodeError:
|
|
476
|
+
waiting_for_permission = False
|
|
477
|
+
pass
|
|
478
|
+
except Exception:
|
|
479
|
+
logger.exception("Failed to process agent message for thread %d", thread_id)
|
|
480
|
+
if save_failed and done_data is not None:
|
|
481
|
+
# Carry the warning INSIDE the done payload. A separate SSE event yielded
|
|
482
|
+
# after `done` is dropped by the client: the done branch tears down the
|
|
483
|
+
# status tracker (destroyed=true) and onWarning early-returns (#676/#729).
|
|
484
|
+
done_data["save_warning"] = _SAVE_FAILED_WARNING
|
|
485
|
+
yield f"data: {json.dumps(done_data, ensure_ascii=False)}\n\n"
|
|
486
|
+
else:
|
|
487
|
+
yield chunk
|
|
488
|
+
if done_data is not None:
|
|
489
|
+
break
|
|
490
|
+
finally:
|
|
491
|
+
if pending_chunk_task is not None and not pending_chunk_task.done():
|
|
492
|
+
pending_chunk_task.cancel()
|
|
493
|
+
pending_chunk_task.add_done_callback(_consume_abandoned_next_chunk)
|
|
494
|
+
done, _ = await asyncio.wait({pending_chunk_task}, timeout=1.0)
|
|
495
|
+
if not done:
|
|
496
|
+
logger.debug(
|
|
497
|
+
"Agent stream next-chunk task did not finish after cancellation for thread %d",
|
|
498
|
+
thread_id,
|
|
499
|
+
)
|
|
500
|
+
if pending_chunk_task is None or pending_chunk_task.done():
|
|
501
|
+
try:
|
|
502
|
+
await agen.aclose()
|
|
503
|
+
except Exception:
|
|
504
|
+
logger.debug("Error closing agent stream for thread %d", thread_id, exc_info=True)
|
|
505
|
+
|
|
506
|
+
return AgentStream(generate())
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Response mapping for the agent web domain."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastapi import Request
|
|
9
|
+
from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse
|
|
10
|
+
|
|
11
|
+
from tg_webui import deps
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class AgentTemplate:
|
|
16
|
+
name: str
|
|
17
|
+
context: dict[str, Any]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class AgentJson:
|
|
22
|
+
content: Any
|
|
23
|
+
status_code: int = 200
|
|
24
|
+
headers: dict[str, str] | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class AgentRedirect:
|
|
29
|
+
url: str
|
|
30
|
+
status_code: int = 303
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class AgentStream:
|
|
35
|
+
iterator: Any
|
|
36
|
+
media_type: str = "text/event-stream"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
AgentResult = AgentTemplate | AgentJson | AgentRedirect | AgentStream | Any
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def agent_response(request: Request, result: AgentResult):
|
|
43
|
+
if isinstance(result, AgentTemplate):
|
|
44
|
+
return deps.get_templates(request).TemplateResponse(request, result.name, result.context)
|
|
45
|
+
if isinstance(result, AgentJson):
|
|
46
|
+
return JSONResponse(result.content, status_code=result.status_code, headers=result.headers)
|
|
47
|
+
if isinstance(result, AgentRedirect):
|
|
48
|
+
return RedirectResponse(url=result.url, status_code=result.status_code)
|
|
49
|
+
if isinstance(result, AgentStream):
|
|
50
|
+
return StreamingResponse(result.iterator, media_type=result.media_type)
|
|
51
|
+
return result
|