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.
Files changed (192) hide show
  1. tg_webui/__init__.py +1 -0
  2. tg_webui/agent/__init__.py +0 -0
  3. tg_webui/agent/forms.py +18 -0
  4. tg_webui/agent/handlers.py +506 -0
  5. tg_webui/agent/responses.py +51 -0
  6. tg_webui/app.py +542 -0
  7. tg_webui/assembly.py +206 -0
  8. tg_webui/bootstrap.py +41 -0
  9. tg_webui/channels/__init__.py +0 -0
  10. tg_webui/channels/forms.py +17 -0
  11. tg_webui/channels/handlers.py +166 -0
  12. tg_webui/channels/responses.py +45 -0
  13. tg_webui/container.py +29 -0
  14. tg_webui/csrf.py +134 -0
  15. tg_webui/deps.py +393 -0
  16. tg_webui/dialogs/__init__.py +1 -0
  17. tg_webui/dialogs/forms.py +221 -0
  18. tg_webui/dialogs/handlers.py +555 -0
  19. tg_webui/dialogs/responses.py +70 -0
  20. tg_webui/embedded_worker.py +209 -0
  21. tg_webui/filter/__init__.py +0 -0
  22. tg_webui/filter/forms.py +81 -0
  23. tg_webui/filter/handlers.py +270 -0
  24. tg_webui/filter/responses.py +48 -0
  25. tg_webui/images/__init__.py +0 -0
  26. tg_webui/images/forms.py +36 -0
  27. tg_webui/images/handlers.py +110 -0
  28. tg_webui/images/responses.py +34 -0
  29. tg_webui/panel_auth.py +93 -0
  30. tg_webui/paths.py +7 -0
  31. tg_webui/photo_loader/__init__.py +2 -0
  32. tg_webui/photo_loader/forms.py +136 -0
  33. tg_webui/photo_loader/handlers.py +509 -0
  34. tg_webui/photo_loader/responses.py +42 -0
  35. tg_webui/pipelines/__init__.py +0 -0
  36. tg_webui/pipelines/forms.py +206 -0
  37. tg_webui/pipelines/handlers.py +1051 -0
  38. tg_webui/pipelines/responses.py +85 -0
  39. tg_webui/py.typed +0 -0
  40. tg_webui/query_params.py +27 -0
  41. tg_webui/redirects.py +18 -0
  42. tg_webui/responses.py +71 -0
  43. tg_webui/routes/__init__.py +0 -0
  44. tg_webui/routes/accounts.py +221 -0
  45. tg_webui/routes/agent.py +105 -0
  46. tg_webui/routes/analytics.py +585 -0
  47. tg_webui/routes/auth.py +391 -0
  48. tg_webui/routes/calendar.py +155 -0
  49. tg_webui/routes/channel_collection.py +198 -0
  50. tg_webui/routes/channel_renames.py +194 -0
  51. tg_webui/routes/channels.py +100 -0
  52. tg_webui/routes/dashboard.py +106 -0
  53. tg_webui/routes/debug.py +157 -0
  54. tg_webui/routes/dialogs.py +234 -0
  55. tg_webui/routes/dms.py +139 -0
  56. tg_webui/routes/export.py +87 -0
  57. tg_webui/routes/filter.py +86 -0
  58. tg_webui/routes/images.py +49 -0
  59. tg_webui/routes/import_channels.py +87 -0
  60. tg_webui/routes/jobs.py +257 -0
  61. tg_webui/routes/moderation.py +177 -0
  62. tg_webui/routes/photo_loader.py +217 -0
  63. tg_webui/routes/pipelines.py +374 -0
  64. tg_webui/routes/plugin_tools.py +18 -0
  65. tg_webui/routes/rss.py +144 -0
  66. tg_webui/routes/scheduler.py +167 -0
  67. tg_webui/routes/search.py +115 -0
  68. tg_webui/routes/search_queries.py +91 -0
  69. tg_webui/routes/settings.py +291 -0
  70. tg_webui/routes/telegram_commands.py +48 -0
  71. tg_webui/scheduler/__init__.py +1 -0
  72. tg_webui/scheduler/commands.py +30 -0
  73. tg_webui/scheduler/context.py +636 -0
  74. tg_webui/scheduler/forms.py +52 -0
  75. tg_webui/scheduler/handlers.py +301 -0
  76. tg_webui/scheduler/responses.py +87 -0
  77. tg_webui/schemas/__init__.py +30 -0
  78. tg_webui/schemas/accounts.py +36 -0
  79. tg_webui/schemas/agent.py +26 -0
  80. tg_webui/schemas/analytics.py +155 -0
  81. tg_webui/schemas/calendar.py +32 -0
  82. tg_webui/schemas/common.py +31 -0
  83. tg_webui/schemas/dialogs.py +71 -0
  84. tg_webui/schemas/pipelines.py +110 -0
  85. tg_webui/search/__init__.py +1 -0
  86. tg_webui/search/forms.py +31 -0
  87. tg_webui/search/handlers.py +522 -0
  88. tg_webui/search/responses.py +42 -0
  89. tg_webui/search_queries/__init__.py +0 -0
  90. tg_webui/search_queries/forms.py +48 -0
  91. tg_webui/search_queries/handlers.py +92 -0
  92. tg_webui/search_queries/responses.py +37 -0
  93. tg_webui/session.py +60 -0
  94. tg_webui/settings/__init__.py +2 -0
  95. tg_webui/settings/forms.py +251 -0
  96. tg_webui/settings/handlers.py +1322 -0
  97. tg_webui/settings/responses.py +58 -0
  98. tg_webui/snapshot_refresher.py +82 -0
  99. tg_webui/static/_busy_button.js +26 -0
  100. tg_webui/static/_theme_init.js +1 -0
  101. tg_webui/static/js/app.js +393 -0
  102. tg_webui/static/js/nav-progress.js +101 -0
  103. tg_webui/static/js/searchable-picker.js +275 -0
  104. tg_webui/static/settings.js +491 -0
  105. tg_webui/static/style.css +592 -0
  106. tg_webui/template_globals.py +321 -0
  107. tg_webui/templates/_debug_errors.html +34 -0
  108. tg_webui/templates/_debug_logs.html +10 -0
  109. tg_webui/templates/_dry_run_results.html +52 -0
  110. tg_webui/templates/_macros.html +100 -0
  111. tg_webui/templates/_macros_ui.html +86 -0
  112. tg_webui/templates/_timing_rows.html +13 -0
  113. tg_webui/templates/agent/_threads.html +120 -0
  114. tg_webui/templates/agent.html +1358 -0
  115. tg_webui/templates/analytics/_channel_selector.html +8 -0
  116. tg_webui/templates/analytics/_content_pipelines.html +43 -0
  117. tg_webui/templates/analytics/_content_summary.html +32 -0
  118. tg_webui/templates/analytics/_engagement.html +19 -0
  119. tg_webui/templates/analytics/_hourly.html +19 -0
  120. tg_webui/templates/analytics/_rating_verdict.html +28 -0
  121. tg_webui/templates/analytics/_top_messages.html +62 -0
  122. tg_webui/templates/analytics/_trends_channels.html +25 -0
  123. tg_webui/templates/analytics/_trends_emojis.html +12 -0
  124. tg_webui/templates/analytics/_trends_topics.html +16 -0
  125. tg_webui/templates/analytics/channel_ratings.html +90 -0
  126. tg_webui/templates/analytics/channels.html +422 -0
  127. tg_webui/templates/analytics/content.html +36 -0
  128. tg_webui/templates/analytics/trends.html +49 -0
  129. tg_webui/templates/analytics.html +63 -0
  130. tg_webui/templates/base.html +104 -0
  131. tg_webui/templates/calendar/_grid.html +64 -0
  132. tg_webui/templates/calendar/_stats.html +18 -0
  133. tg_webui/templates/calendar/_upcoming.html +33 -0
  134. tg_webui/templates/calendar.html +57 -0
  135. tg_webui/templates/channel_renames.html +61 -0
  136. tg_webui/templates/channel_review.html +56 -0
  137. tg_webui/templates/channels/_list.html +120 -0
  138. tg_webui/templates/channels.html +152 -0
  139. tg_webui/templates/dashboard/_overview.html +71 -0
  140. tg_webui/templates/dashboard.html +11 -0
  141. tg_webui/templates/debug.html +34 -0
  142. tg_webui/templates/debug_timing.html +30 -0
  143. tg_webui/templates/dialogs/_history.html +205 -0
  144. tg_webui/templates/dialogs/_list.html +176 -0
  145. tg_webui/templates/dialogs.html +122 -0
  146. tg_webui/templates/dialogs_create_channel.html +57 -0
  147. tg_webui/templates/dms.html +106 -0
  148. tg_webui/templates/error.html +20 -0
  149. tg_webui/templates/filter_manage.html +13 -0
  150. tg_webui/templates/filter_manage_content.html +245 -0
  151. tg_webui/templates/images.html +210 -0
  152. tg_webui/templates/import_channels.html +72 -0
  153. tg_webui/templates/jobs.html +23 -0
  154. tg_webui/templates/jobs_table.html +216 -0
  155. tg_webui/templates/login.html +195 -0
  156. tg_webui/templates/moderation/view.html +123 -0
  157. tg_webui/templates/moderation.html +15 -0
  158. tg_webui/templates/moderation_content.html +111 -0
  159. tg_webui/templates/photo_loader.html +13 -0
  160. tg_webui/templates/photo_loader_batch_row.html +29 -0
  161. tg_webui/templates/photo_loader_content.html +597 -0
  162. tg_webui/templates/pipelines/create.html +892 -0
  163. tg_webui/templates/pipelines/edit.html +292 -0
  164. tg_webui/templates/pipelines/generate.html +124 -0
  165. tg_webui/templates/pipelines/templates.html +187 -0
  166. tg_webui/templates/pipelines.html +13 -0
  167. tg_webui/templates/pipelines_content.html +316 -0
  168. tg_webui/templates/qr_login.html +104 -0
  169. tg_webui/templates/scheduler/_health.html +93 -0
  170. tg_webui/templates/scheduler/_jobs.html +114 -0
  171. tg_webui/templates/scheduler.html +90 -0
  172. tg_webui/templates/search.html +181 -0
  173. tg_webui/templates/search_queries.html +269 -0
  174. tg_webui/templates/search_results.html +121 -0
  175. tg_webui/templates/settings/_accounts.html +153 -0
  176. tg_webui/templates/settings/_credentials.html +41 -0
  177. tg_webui/templates/settings/_devmode.html +269 -0
  178. tg_webui/templates/settings/_filters.html +36 -0
  179. tg_webui/templates/settings/_image_providers.html +230 -0
  180. tg_webui/templates/settings/_notifications.html +52 -0
  181. tg_webui/templates/settings/_plugins.html +18 -0
  182. tg_webui/templates/settings/_scheduler.html +17 -0
  183. tg_webui/templates/settings/_semantic.html +96 -0
  184. tg_webui/templates/settings/_tool_permissions.html +208 -0
  185. tg_webui/templates/settings/_translation.html +79 -0
  186. tg_webui/templates/settings.html +83 -0
  187. tg_webui/templates/web_login.html +39 -0
  188. tg_webui-0.0.1.dist-info/METADATA +52 -0
  189. tg_webui-0.0.1.dist-info/RECORD +192 -0
  190. tg_webui-0.0.1.dist-info/WHEEL +5 -0
  191. tg_webui-0.0.1.dist-info/licenses/LICENSE +21 -0
  192. 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
@@ -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