flowent 0.2.3 → 0.3.0
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.
- package/README.md +3 -3
- package/backend/README.md +3 -3
- package/backend/pyproject.toml +1 -1
- package/backend/src/flowent/agent.py +1 -1
- package/backend/src/flowent/api_models.py +103 -0
- package/backend/src/flowent/app.py +151 -0
- package/backend/src/flowent/cli.py +13 -4
- package/backend/src/flowent/compact.py +34 -13
- package/backend/src/flowent/llm.py +6 -8
- package/backend/src/flowent/logging.py +7 -1
- package/backend/src/flowent/main.py +18 -1989
- package/backend/src/flowent/mcp.py +231 -44
- package/backend/src/flowent/network.py +5 -0
- package/backend/src/flowent/permissions.py +5 -1
- package/backend/src/flowent/provider_connections.py +42 -0
- package/backend/src/flowent/routes/__init__.py +0 -0
- package/backend/src/flowent/routes/integrations.py +105 -0
- package/backend/src/flowent/routes/permissions.py +36 -0
- package/backend/src/flowent/routes/providers.py +30 -0
- package/backend/src/flowent/routes/system.py +49 -0
- package/backend/src/flowent/routes/workflow_routes.py +63 -0
- package/backend/src/flowent/routes/workspace.py +105 -0
- package/backend/src/flowent/sandbox.py +1 -1
- package/backend/src/flowent/state/__init__.py +53 -0
- package/backend/src/flowent/state/models.py +257 -0
- package/backend/src/flowent/state/schema.py +186 -0
- package/backend/src/flowent/state/store.py +1013 -0
- package/backend/src/flowent/static/assets/index-CvWZZMtK.css +2 -0
- package/backend/src/flowent/static/assets/index-ma2v8oW7.js +90 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/storage.py +52 -1254
- package/backend/src/flowent/system_tools.py +25 -0
- package/backend/src/flowent/tools.py +4 -2
- package/backend/src/flowent/usage.py +9 -4
- package/backend/src/flowent/workflows.py +282 -0
- package/backend/src/flowent/workspace/__init__.py +0 -0
- package/backend/src/flowent/workspace/context.py +249 -0
- package/backend/src/flowent/workspace/events.py +180 -0
- package/backend/src/flowent/workspace/output.py +274 -0
- package/backend/src/flowent/workspace/runtime.py +1041 -0
- package/backend/uv.lock +1 -1
- package/dist/frontend/assets/index-CvWZZMtK.css +2 -0
- package/dist/frontend/assets/index-ma2v8oW7.js +90 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +1 -1
- package/backend/src/flowent/static/assets/index-D7t9qNrC.js +0 -82
- package/backend/src/flowent/static/assets/index-DufpDl8x.css +0 -2
- package/dist/frontend/assets/index-D7t9qNrC.js +0 -82
- package/dist/frontend/assets/index-DufpDl8x.css +0 -2
|
@@ -5,16 +5,23 @@ import json
|
|
|
5
5
|
import logging
|
|
6
6
|
import os
|
|
7
7
|
import re
|
|
8
|
+
import sys
|
|
8
9
|
from collections.abc import Callable
|
|
9
|
-
from contextlib import AsyncExitStack
|
|
10
|
+
from contextlib import AsyncExitStack, suppress
|
|
11
|
+
from dataclasses import dataclass
|
|
10
12
|
from importlib import import_module
|
|
11
|
-
from typing import Any, Protocol
|
|
13
|
+
from typing import Any, Protocol, TextIO, cast
|
|
12
14
|
|
|
13
15
|
from flowent.storage import StateStore, StoredMcpServer, StoredMcpTool
|
|
14
16
|
from flowent.tools import ToolResult
|
|
15
17
|
|
|
16
18
|
logger = logging.getLogger("flowent.mcp")
|
|
17
19
|
MCP_CONNECT_TIMEOUT_SECONDS = 10
|
|
20
|
+
PYTHON_TRACEBACK_START = "Traceback (most recent call last):"
|
|
21
|
+
PYTHON_TRACEBACK_TERMINAL_PATTERN = re.compile(
|
|
22
|
+
r"^(?:[A-Za-z_][A-Za-z0-9_.]*(?:Error|Exception|Interrupt|Warning)|"
|
|
23
|
+
r"BaseExceptionGroup|ExceptionGroup)(?::|$)"
|
|
24
|
+
)
|
|
18
25
|
|
|
19
26
|
|
|
20
27
|
class McpTransport(Protocol):
|
|
@@ -119,54 +126,230 @@ def expand_mcp_config(config: dict[str, object]) -> dict[str, object]:
|
|
|
119
126
|
return expanded if isinstance(expanded, dict) else {}
|
|
120
127
|
|
|
121
128
|
|
|
129
|
+
@dataclass
|
|
130
|
+
class _McpConnection:
|
|
131
|
+
close_event: asyncio.Event
|
|
132
|
+
ready: asyncio.Future[list[dict[str, object]]]
|
|
133
|
+
owner_task: asyncio.Task[None] | None = None
|
|
134
|
+
session: Any = None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class _McpStdioErrorFilter:
|
|
138
|
+
def __init__(self, target: TextIO) -> None:
|
|
139
|
+
self.target = target
|
|
140
|
+
self.line_buffer = ""
|
|
141
|
+
self.traceback_lines: list[str] | None = None
|
|
142
|
+
|
|
143
|
+
def feed(self, text: str) -> None:
|
|
144
|
+
self.line_buffer += text
|
|
145
|
+
while "\n" in self.line_buffer:
|
|
146
|
+
line, self.line_buffer = self.line_buffer.split("\n", 1)
|
|
147
|
+
self.feed_line(f"{line}\n")
|
|
148
|
+
|
|
149
|
+
def finish(self) -> None:
|
|
150
|
+
if self.line_buffer:
|
|
151
|
+
self.feed_line(self.line_buffer)
|
|
152
|
+
self.line_buffer = ""
|
|
153
|
+
if self.traceback_lines is not None:
|
|
154
|
+
self.write("".join(self.traceback_lines))
|
|
155
|
+
self.traceback_lines = None
|
|
156
|
+
|
|
157
|
+
def feed_line(self, line: str) -> None:
|
|
158
|
+
stripped_line = line.rstrip("\r\n")
|
|
159
|
+
if self.traceback_lines is not None:
|
|
160
|
+
self.traceback_lines.append(line)
|
|
161
|
+
if stripped_line == "KeyboardInterrupt":
|
|
162
|
+
self.traceback_lines = None
|
|
163
|
+
return
|
|
164
|
+
if PYTHON_TRACEBACK_TERMINAL_PATTERN.match(stripped_line):
|
|
165
|
+
self.write("".join(self.traceback_lines))
|
|
166
|
+
self.traceback_lines = None
|
|
167
|
+
return
|
|
168
|
+
if stripped_line == PYTHON_TRACEBACK_START:
|
|
169
|
+
self.traceback_lines = [line]
|
|
170
|
+
return
|
|
171
|
+
self.write(line)
|
|
172
|
+
|
|
173
|
+
def write(self, text: str) -> None:
|
|
174
|
+
self.target.write(text)
|
|
175
|
+
self.target.flush()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class _McpStdioErrorLog:
|
|
179
|
+
def __init__(self, target: TextIO | None = None) -> None:
|
|
180
|
+
self.target = target or sys.stderr
|
|
181
|
+
self.filter = _McpStdioErrorFilter(self.target)
|
|
182
|
+
self.read_fd, write_fd = os.pipe()
|
|
183
|
+
self.write_file = os.fdopen(write_fd, "wb", buffering=0)
|
|
184
|
+
self.drain_task: asyncio.Task[None] | None = None
|
|
185
|
+
|
|
186
|
+
async def __aenter__(self) -> _McpStdioErrorLog:
|
|
187
|
+
self.drain_task = asyncio.create_task(self.drain())
|
|
188
|
+
return self
|
|
189
|
+
|
|
190
|
+
async def __aexit__(
|
|
191
|
+
self,
|
|
192
|
+
exc_type: type[BaseException] | None,
|
|
193
|
+
exc: BaseException | None,
|
|
194
|
+
traceback: object,
|
|
195
|
+
) -> None:
|
|
196
|
+
self.close_write_file()
|
|
197
|
+
if self.drain_task is not None:
|
|
198
|
+
await asyncio.gather(self.drain_task, return_exceptions=True)
|
|
199
|
+
else:
|
|
200
|
+
self.close_read_fd()
|
|
201
|
+
|
|
202
|
+
def fileno(self) -> int:
|
|
203
|
+
return self.write_file.fileno()
|
|
204
|
+
|
|
205
|
+
async def drain(self) -> None:
|
|
206
|
+
try:
|
|
207
|
+
while True:
|
|
208
|
+
chunk = await asyncio.to_thread(os.read, self.read_fd, 4096)
|
|
209
|
+
if not chunk:
|
|
210
|
+
break
|
|
211
|
+
self.filter.feed(chunk.decode("utf-8", errors="replace"))
|
|
212
|
+
except OSError:
|
|
213
|
+
pass
|
|
214
|
+
finally:
|
|
215
|
+
self.filter.finish()
|
|
216
|
+
self.close_read_fd()
|
|
217
|
+
|
|
218
|
+
def close_write_file(self) -> None:
|
|
219
|
+
with suppress(OSError, ValueError):
|
|
220
|
+
self.write_file.close()
|
|
221
|
+
|
|
222
|
+
def close_read_fd(self) -> None:
|
|
223
|
+
with suppress(OSError):
|
|
224
|
+
os.close(self.read_fd)
|
|
225
|
+
|
|
226
|
+
|
|
122
227
|
class DefaultMcpTransport:
|
|
123
228
|
def __init__(self) -> None:
|
|
124
|
-
self.
|
|
125
|
-
self._stacks: dict[str, AsyncExitStack] = {}
|
|
229
|
+
self._connections: dict[str, _McpConnection] = {}
|
|
126
230
|
|
|
127
231
|
async def connect(self, server: StoredMcpServer) -> list[dict[str, object]]:
|
|
232
|
+
await self.disconnect(server.id)
|
|
233
|
+
loop = asyncio.get_running_loop()
|
|
234
|
+
connection = _McpConnection(
|
|
235
|
+
close_event=asyncio.Event(),
|
|
236
|
+
ready=loop.create_future(),
|
|
237
|
+
)
|
|
238
|
+
connection.owner_task = asyncio.create_task(
|
|
239
|
+
self._run_connection(server, connection)
|
|
240
|
+
)
|
|
241
|
+
self._connections[server.id] = connection
|
|
242
|
+
try:
|
|
243
|
+
return await asyncio.shield(connection.ready)
|
|
244
|
+
except asyncio.CancelledError:
|
|
245
|
+
await self._close_connection(
|
|
246
|
+
server.id,
|
|
247
|
+
connection,
|
|
248
|
+
cancel_owner=True,
|
|
249
|
+
suppress_errors=True,
|
|
250
|
+
)
|
|
251
|
+
raise
|
|
252
|
+
except Exception:
|
|
253
|
+
await self._close_connection(
|
|
254
|
+
server.id,
|
|
255
|
+
connection,
|
|
256
|
+
cancel_owner=False,
|
|
257
|
+
suppress_errors=True,
|
|
258
|
+
)
|
|
259
|
+
raise
|
|
260
|
+
|
|
261
|
+
async def _run_connection(
|
|
262
|
+
self,
|
|
263
|
+
server: StoredMcpServer,
|
|
264
|
+
connection: _McpConnection,
|
|
265
|
+
) -> None:
|
|
128
266
|
from mcp import ClientSession
|
|
129
267
|
from mcp.client.stdio import stdio_client
|
|
130
268
|
|
|
131
|
-
await self.disconnect(server.id)
|
|
132
269
|
stack = AsyncExitStack()
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
http_module
|
|
140
|
-
|
|
141
|
-
|
|
270
|
+
try:
|
|
271
|
+
async with stack:
|
|
272
|
+
config = expand_mcp_config(server.config)
|
|
273
|
+
if server.type == "url":
|
|
274
|
+
http_module = import_module("mcp.client.streamable_http")
|
|
275
|
+
http_headers = self._streamable_http_headers(config) or None
|
|
276
|
+
if hasattr(http_module, "streamablehttp_client"):
|
|
277
|
+
read_stream, write_stream, _ = await stack.enter_async_context(
|
|
278
|
+
http_module.streamablehttp_client(
|
|
279
|
+
server.url or str(config.get("url") or ""),
|
|
280
|
+
headers=http_headers,
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
else:
|
|
284
|
+
import httpx
|
|
285
|
+
|
|
286
|
+
http_client = await stack.enter_async_context(
|
|
287
|
+
httpx.AsyncClient(headers=http_headers)
|
|
288
|
+
)
|
|
289
|
+
read_stream, write_stream, _ = await stack.enter_async_context(
|
|
290
|
+
http_module.streamable_http_client(
|
|
291
|
+
server.url or str(config.get("url") or ""),
|
|
292
|
+
http_client=http_client,
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
else:
|
|
296
|
+
stdio_errlog = await stack.enter_async_context(_McpStdioErrorLog())
|
|
297
|
+
read_stream, write_stream = await stack.enter_async_context(
|
|
298
|
+
stdio_client(
|
|
299
|
+
self._stdio_parameters(server, config),
|
|
300
|
+
errlog=cast(TextIO, stdio_errlog),
|
|
301
|
+
)
|
|
142
302
|
)
|
|
303
|
+
session = await stack.enter_async_context(
|
|
304
|
+
ClientSession(read_stream, write_stream)
|
|
143
305
|
)
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
read_stream, write_stream, _ = await stack.enter_async_context(
|
|
151
|
-
http_module.streamable_http_client(
|
|
152
|
-
server.url or str(config.get("url") or ""),
|
|
153
|
-
http_client=http_client,
|
|
306
|
+
await session.initialize()
|
|
307
|
+
result = await session.list_tools()
|
|
308
|
+
connection.session = session
|
|
309
|
+
if not connection.ready.done():
|
|
310
|
+
connection.ready.set_result(
|
|
311
|
+
[self._model_dump(tool) for tool in result.tools]
|
|
154
312
|
)
|
|
155
|
-
)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
)
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
self
|
|
169
|
-
|
|
313
|
+
await connection.close_event.wait()
|
|
314
|
+
except asyncio.CancelledError:
|
|
315
|
+
if not connection.ready.done():
|
|
316
|
+
connection.ready.cancel()
|
|
317
|
+
raise
|
|
318
|
+
except Exception as error:
|
|
319
|
+
if not connection.ready.done():
|
|
320
|
+
connection.ready.set_exception(error)
|
|
321
|
+
raise
|
|
322
|
+
finally:
|
|
323
|
+
connection.session = None
|
|
324
|
+
|
|
325
|
+
async def _close_connection(
|
|
326
|
+
self,
|
|
327
|
+
server_id: str,
|
|
328
|
+
connection: _McpConnection,
|
|
329
|
+
*,
|
|
330
|
+
cancel_owner: bool,
|
|
331
|
+
suppress_errors: bool = False,
|
|
332
|
+
) -> None:
|
|
333
|
+
if self._connections.get(server_id) is connection:
|
|
334
|
+
self._connections.pop(server_id, None)
|
|
335
|
+
connection.session = None
|
|
336
|
+
owner_task = connection.owner_task
|
|
337
|
+
if owner_task is None:
|
|
338
|
+
return
|
|
339
|
+
if not owner_task.done():
|
|
340
|
+
if cancel_owner:
|
|
341
|
+
owner_task.cancel()
|
|
342
|
+
else:
|
|
343
|
+
connection.close_event.set()
|
|
344
|
+
try:
|
|
345
|
+
await asyncio.shield(owner_task)
|
|
346
|
+
except asyncio.CancelledError:
|
|
347
|
+
if cancel_owner or suppress_errors:
|
|
348
|
+
return
|
|
349
|
+
raise
|
|
350
|
+
except Exception:
|
|
351
|
+
if not suppress_errors:
|
|
352
|
+
raise
|
|
170
353
|
|
|
171
354
|
def _streamable_http_headers(self, config: dict[str, object]) -> dict[str, str]:
|
|
172
355
|
headers: dict[str, str] = {}
|
|
@@ -238,10 +421,13 @@ class DefaultMcpTransport:
|
|
|
238
421
|
)
|
|
239
422
|
|
|
240
423
|
async def disconnect(self, server_id: str) -> None:
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
424
|
+
connection = self._connections.pop(server_id, None)
|
|
425
|
+
if connection is not None:
|
|
426
|
+
await self._close_connection(
|
|
427
|
+
server_id,
|
|
428
|
+
connection,
|
|
429
|
+
cancel_owner=not connection.ready.done(),
|
|
430
|
+
)
|
|
245
431
|
|
|
246
432
|
async def call_tool(
|
|
247
433
|
self,
|
|
@@ -249,7 +435,8 @@ class DefaultMcpTransport:
|
|
|
249
435
|
tool_name: str,
|
|
250
436
|
arguments: dict[str, object],
|
|
251
437
|
) -> dict[str, object]:
|
|
252
|
-
|
|
438
|
+
connection = self._connections.get(server_id)
|
|
439
|
+
session = connection.session if connection is not None else None
|
|
253
440
|
if session is None:
|
|
254
441
|
raise RuntimeError("Server is not connected.")
|
|
255
442
|
result = await session.call_tool(tool_name, arguments=arguments)
|
|
@@ -348,7 +348,11 @@ def tool_failure_text(result: ToolResult) -> str:
|
|
|
348
348
|
stderr = str(result.data.get("stderr", "") or "").strip()
|
|
349
349
|
stdout = str(result.data.get("stdout", "") or "").strip()
|
|
350
350
|
content = result.content.strip()
|
|
351
|
-
|
|
351
|
+
parts: list[str] = []
|
|
352
|
+
for part in [stderr, stdout, content]:
|
|
353
|
+
if part and part not in parts:
|
|
354
|
+
parts.append(part)
|
|
355
|
+
return "\n".join(parts)
|
|
352
356
|
|
|
353
357
|
|
|
354
358
|
async def shell_command_without_sandbox(
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from fastapi import HTTPException
|
|
4
|
+
|
|
5
|
+
from flowent.llm import ProviderConnection
|
|
6
|
+
from flowent.storage import StoredState
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("flowent.provider_connections")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def selected_connection(state: StoredState) -> ProviderConnection:
|
|
12
|
+
provider = next(
|
|
13
|
+
(
|
|
14
|
+
stored_provider
|
|
15
|
+
for stored_provider in state.providers
|
|
16
|
+
if stored_provider.id == state.settings.selected_provider_id
|
|
17
|
+
),
|
|
18
|
+
None,
|
|
19
|
+
)
|
|
20
|
+
if provider is None or not state.settings.selected_model:
|
|
21
|
+
logger.warning("Workspace request blocked because provider or model is missing")
|
|
22
|
+
raise HTTPException(
|
|
23
|
+
status_code=400,
|
|
24
|
+
detail="Choose a provider and model before sending.",
|
|
25
|
+
)
|
|
26
|
+
if not provider.api_key:
|
|
27
|
+
logger.warning("Workspace request blocked because selected provider has no key")
|
|
28
|
+
raise HTTPException(status_code=400, detail="Add a key before sending.")
|
|
29
|
+
|
|
30
|
+
logger.debug(
|
|
31
|
+
"Workspace request using provider=%s model=%s",
|
|
32
|
+
provider.name,
|
|
33
|
+
state.settings.selected_model,
|
|
34
|
+
)
|
|
35
|
+
return ProviderConnection(
|
|
36
|
+
base_url=provider.base_url or None,
|
|
37
|
+
model=state.settings.selected_model,
|
|
38
|
+
name=provider.name,
|
|
39
|
+
provider=provider.type,
|
|
40
|
+
reasoning_effort=state.settings.reasoning_effort,
|
|
41
|
+
secret_reference=provider.api_key,
|
|
42
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI, HTTPException
|
|
4
|
+
|
|
5
|
+
from flowent.api_models import (
|
|
6
|
+
McpImportPreviewRequest,
|
|
7
|
+
McpImportRequest,
|
|
8
|
+
SkillSettingsRequest,
|
|
9
|
+
TelegramSessionApproveRequest,
|
|
10
|
+
)
|
|
11
|
+
from flowent.channels import TelegramBotManager
|
|
12
|
+
from flowent.mcp import McpManager
|
|
13
|
+
from flowent.mcp_import import McpImportDiscovery, discover_imported_mcp_servers
|
|
14
|
+
from flowent.skills import discover_skills, update_skill_enabled
|
|
15
|
+
from flowent.storage import (
|
|
16
|
+
StateStore,
|
|
17
|
+
StoredMcpServer,
|
|
18
|
+
StoredSkill,
|
|
19
|
+
StoredTelegramBot,
|
|
20
|
+
StoredTelegramSession,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def register_integration_routes(
|
|
25
|
+
app: FastAPI,
|
|
26
|
+
*,
|
|
27
|
+
cwd: Path,
|
|
28
|
+
mcp_manager: McpManager,
|
|
29
|
+
store: StateStore,
|
|
30
|
+
telegram_bot_manager: TelegramBotManager,
|
|
31
|
+
) -> None:
|
|
32
|
+
@app.put("/api/mcp/servers")
|
|
33
|
+
async def save_mcp_server(server: StoredMcpServer) -> StoredMcpServer:
|
|
34
|
+
saved_server = store.save_mcp_server(server)
|
|
35
|
+
return await mcp_manager.sync_server(saved_server)
|
|
36
|
+
|
|
37
|
+
@app.post("/api/mcp/import/preview")
|
|
38
|
+
async def preview_mcp_import(
|
|
39
|
+
request: McpImportPreviewRequest,
|
|
40
|
+
) -> McpImportDiscovery:
|
|
41
|
+
return discover_imported_mcp_servers(cwd, source=request.source)
|
|
42
|
+
|
|
43
|
+
@app.post("/api/mcp/import")
|
|
44
|
+
async def import_mcp_servers(request: McpImportRequest) -> list[StoredMcpServer]:
|
|
45
|
+
imported_servers = discover_imported_mcp_servers(
|
|
46
|
+
cwd,
|
|
47
|
+
source=request.source,
|
|
48
|
+
).servers
|
|
49
|
+
existing_servers = {server.id for server in store.read_mcp_servers()}
|
|
50
|
+
for server in imported_servers:
|
|
51
|
+
if server.id != request.server_id:
|
|
52
|
+
continue
|
|
53
|
+
if server.id in existing_servers:
|
|
54
|
+
continue
|
|
55
|
+
store.save_mcp_server(server)
|
|
56
|
+
existing_servers.add(server.id)
|
|
57
|
+
return mcp_manager.servers_with_status(store.read_mcp_servers())
|
|
58
|
+
|
|
59
|
+
@app.delete("/api/mcp/servers/{server_id}")
|
|
60
|
+
async def delete_mcp_server(server_id: str) -> dict[str, bool]:
|
|
61
|
+
await mcp_manager.delete_server(server_id)
|
|
62
|
+
return {"ok": True}
|
|
63
|
+
|
|
64
|
+
@app.post("/api/mcp/servers/{server_id}/reconnect")
|
|
65
|
+
async def reconnect_mcp_server(server_id: str) -> StoredMcpServer:
|
|
66
|
+
try:
|
|
67
|
+
return await mcp_manager.reconnect_server(server_id)
|
|
68
|
+
except KeyError as error:
|
|
69
|
+
raise HTTPException(status_code=404, detail="Server not found.") from error
|
|
70
|
+
|
|
71
|
+
@app.post("/api/mcp/reload")
|
|
72
|
+
async def reload_mcp_servers() -> list[StoredMcpServer]:
|
|
73
|
+
return await mcp_manager.reload()
|
|
74
|
+
|
|
75
|
+
@app.post("/api/skills/reload")
|
|
76
|
+
async def reload_skills() -> list[StoredSkill]:
|
|
77
|
+
return discover_skills(cwd, store)
|
|
78
|
+
|
|
79
|
+
@app.put("/api/skills/{skill_id:path}")
|
|
80
|
+
async def save_skill_settings(
|
|
81
|
+
skill_id: str,
|
|
82
|
+
request: SkillSettingsRequest,
|
|
83
|
+
) -> StoredSkill:
|
|
84
|
+
try:
|
|
85
|
+
return update_skill_enabled(cwd, store, skill_id, request.enabled)
|
|
86
|
+
except KeyError as error:
|
|
87
|
+
raise HTTPException(status_code=404, detail="Skill not found.") from error
|
|
88
|
+
|
|
89
|
+
@app.put("/api/telegram-bot")
|
|
90
|
+
async def save_telegram_bot(telegram_bot: StoredTelegramBot) -> StoredTelegramBot:
|
|
91
|
+
saved_bot = store.save_telegram_bot(telegram_bot)
|
|
92
|
+
await telegram_bot_manager.sync_bot(saved_bot)
|
|
93
|
+
return telegram_bot_manager.bot_with_status(saved_bot)
|
|
94
|
+
|
|
95
|
+
@app.post("/api/telegram-bot/approve")
|
|
96
|
+
async def approve_telegram_session(
|
|
97
|
+
request: TelegramSessionApproveRequest,
|
|
98
|
+
) -> StoredTelegramSession:
|
|
99
|
+
try:
|
|
100
|
+
return store.approve_telegram_session(request.chat_id)
|
|
101
|
+
except KeyError as error:
|
|
102
|
+
raise HTTPException(
|
|
103
|
+
status_code=404,
|
|
104
|
+
detail="Conversation not found.",
|
|
105
|
+
) from error
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI
|
|
4
|
+
|
|
5
|
+
from flowent.api_models import WritablePathListResponse, WritablePathRequest
|
|
6
|
+
from flowent.storage import StateStore, StoredWritablePath
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def normalized_request_path(path: str, cwd: Path) -> Path:
|
|
10
|
+
raw_path = Path(path).expanduser()
|
|
11
|
+
if not raw_path.is_absolute():
|
|
12
|
+
raw_path = cwd / raw_path
|
|
13
|
+
return raw_path.resolve(strict=False)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def register_permission_routes(
|
|
17
|
+
app: FastAPI,
|
|
18
|
+
*,
|
|
19
|
+
cwd: Path,
|
|
20
|
+
store: StateStore,
|
|
21
|
+
) -> None:
|
|
22
|
+
@app.post("/api/permissions/writable-paths")
|
|
23
|
+
async def save_writable_path(
|
|
24
|
+
request: WritablePathRequest,
|
|
25
|
+
) -> StoredWritablePath:
|
|
26
|
+
return store.save_writable_path(normalized_request_path(request.path, cwd))
|
|
27
|
+
|
|
28
|
+
@app.delete("/api/permissions/writable-paths")
|
|
29
|
+
async def delete_writable_path(
|
|
30
|
+
request: WritablePathRequest,
|
|
31
|
+
) -> WritablePathListResponse:
|
|
32
|
+
return WritablePathListResponse(
|
|
33
|
+
writable_paths=store.delete_writable_path(
|
|
34
|
+
normalized_request_path(request.path, cwd)
|
|
35
|
+
)
|
|
36
|
+
)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from fastapi import FastAPI
|
|
2
|
+
|
|
3
|
+
from flowent.api_models import ProviderModelsRequest, ProviderModelsResponse
|
|
4
|
+
from flowent.llm import list_provider_models
|
|
5
|
+
from flowent.storage import StateStore, StoredProvider, StoredSettings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def register_provider_routes(app: FastAPI, *, store: StateStore) -> None:
|
|
9
|
+
@app.post("/api/providers")
|
|
10
|
+
async def save_provider(provider: StoredProvider) -> StoredProvider:
|
|
11
|
+
return store.save_provider(provider)
|
|
12
|
+
|
|
13
|
+
@app.delete("/api/providers/{provider_id}")
|
|
14
|
+
async def delete_provider(provider_id: str) -> dict[str, bool]:
|
|
15
|
+
store.delete_provider(provider_id)
|
|
16
|
+
return {"ok": True}
|
|
17
|
+
|
|
18
|
+
@app.post("/api/providers/models")
|
|
19
|
+
async def provider_models(request: ProviderModelsRequest) -> ProviderModelsResponse:
|
|
20
|
+
return ProviderModelsResponse(
|
|
21
|
+
models=list_provider_models(
|
|
22
|
+
base_url=request.base_url,
|
|
23
|
+
provider=request.provider,
|
|
24
|
+
secret_reference=request.secret_reference,
|
|
25
|
+
),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
@app.put("/api/settings")
|
|
29
|
+
async def save_settings(settings: StoredSettings) -> StoredSettings:
|
|
30
|
+
return store.save_settings(settings)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI
|
|
4
|
+
|
|
5
|
+
from flowent._version import __version__
|
|
6
|
+
from flowent.api_models import AboutResponse
|
|
7
|
+
from flowent.channels import TelegramBotManager
|
|
8
|
+
from flowent.mcp import McpManager
|
|
9
|
+
from flowent.skills import discover_skills
|
|
10
|
+
from flowent.storage import StateStore, StoredState
|
|
11
|
+
from flowent.workspace.context import state_with_current_model_context_window
|
|
12
|
+
from flowent.workspace.runtime import WorkspaceRuntime
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def register_system_routes(
|
|
16
|
+
app: FastAPI,
|
|
17
|
+
*,
|
|
18
|
+
cwd: Path,
|
|
19
|
+
mcp_manager: McpManager,
|
|
20
|
+
runtime: WorkspaceRuntime,
|
|
21
|
+
store: StateStore,
|
|
22
|
+
telegram_bot_manager: TelegramBotManager,
|
|
23
|
+
) -> None:
|
|
24
|
+
@app.get("/api/health")
|
|
25
|
+
async def health() -> dict[str, str]:
|
|
26
|
+
return {"status": "ok"}
|
|
27
|
+
|
|
28
|
+
@app.get("/api/state")
|
|
29
|
+
async def app_state() -> StoredState:
|
|
30
|
+
state = state_with_current_model_context_window(store.read_state())
|
|
31
|
+
active_run = runtime.active_run()
|
|
32
|
+
update: dict[str, object] = {
|
|
33
|
+
"active_run_event_index": active_run.latest_event_index
|
|
34
|
+
if active_run
|
|
35
|
+
else 0,
|
|
36
|
+
"active_run_id": active_run.id
|
|
37
|
+
if active_run and not active_run.is_done
|
|
38
|
+
else None,
|
|
39
|
+
"mcp_servers": mcp_manager.servers_with_status(state.mcp_servers),
|
|
40
|
+
"skills": discover_skills(cwd, store),
|
|
41
|
+
}
|
|
42
|
+
update["telegram_bot"] = telegram_bot_manager.bot_with_status(
|
|
43
|
+
state.telegram_bot
|
|
44
|
+
)
|
|
45
|
+
return state.model_copy(update=update)
|
|
46
|
+
|
|
47
|
+
@app.get("/api/about")
|
|
48
|
+
async def about() -> AboutResponse:
|
|
49
|
+
return AboutResponse(version=__version__)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from fastapi import FastAPI, HTTPException
|
|
2
|
+
|
|
3
|
+
from flowent.llm import CompletionCallable
|
|
4
|
+
from flowent.provider_connections import selected_connection
|
|
5
|
+
from flowent.storage import StateStore, StoredWorkflow
|
|
6
|
+
from flowent.workflows import (
|
|
7
|
+
WorkflowRunResponse,
|
|
8
|
+
run_workflow_definition,
|
|
9
|
+
validate_workflow_draft,
|
|
10
|
+
workflow_requires_connection,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def register_workflow_routes(
|
|
15
|
+
app: FastAPI,
|
|
16
|
+
*,
|
|
17
|
+
chat_completion: CompletionCallable | None,
|
|
18
|
+
store: StateStore,
|
|
19
|
+
) -> None:
|
|
20
|
+
@app.put("/api/workflows")
|
|
21
|
+
async def save_workflow(workflow: StoredWorkflow) -> StoredWorkflow:
|
|
22
|
+
try:
|
|
23
|
+
return store.save_workflow(
|
|
24
|
+
validate_workflow_draft(
|
|
25
|
+
workflow.model_copy(
|
|
26
|
+
update={"name": workflow.name.strip() or "Untitled Workflow"}
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
except ValueError as error:
|
|
31
|
+
raise HTTPException(status_code=400, detail=str(error)) from error
|
|
32
|
+
|
|
33
|
+
@app.delete("/api/workflows/{workflow_id}")
|
|
34
|
+
async def delete_workflow(workflow_id: str) -> dict[str, bool]:
|
|
35
|
+
store.delete_workflow(workflow_id)
|
|
36
|
+
return {"ok": True}
|
|
37
|
+
|
|
38
|
+
@app.post("/api/workflows/{workflow_id}/run")
|
|
39
|
+
async def run_workflow(workflow_id: str) -> WorkflowRunResponse:
|
|
40
|
+
workflow = next(
|
|
41
|
+
(
|
|
42
|
+
current_workflow
|
|
43
|
+
for current_workflow in store.read_workflows()
|
|
44
|
+
if current_workflow.id == workflow_id
|
|
45
|
+
),
|
|
46
|
+
None,
|
|
47
|
+
)
|
|
48
|
+
if workflow is None:
|
|
49
|
+
raise HTTPException(status_code=404, detail="Workflow not found.")
|
|
50
|
+
try:
|
|
51
|
+
connection = (
|
|
52
|
+
selected_connection(store.read_state())
|
|
53
|
+
if workflow_requires_connection(workflow.definition)
|
|
54
|
+
else None
|
|
55
|
+
)
|
|
56
|
+
return await run_workflow_definition(
|
|
57
|
+
completion=chat_completion,
|
|
58
|
+
connection=connection,
|
|
59
|
+
definition=workflow.definition,
|
|
60
|
+
workflow_id=workflow.id,
|
|
61
|
+
)
|
|
62
|
+
except ValueError as error:
|
|
63
|
+
raise HTTPException(status_code=400, detail=str(error)) from error
|