flowent 0.2.4 → 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 +2 -0
- package/backend/src/flowent/main.py +18 -1994
- package/backend/src/flowent/mcp.py +100 -2
- package/backend/src/flowent/network.py +5 -0
- 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/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 -1318
- 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-BH30iLzb.css +0 -2
- package/backend/src/flowent/static/assets/index-sBFt3ORj.js +0 -84
- package/dist/frontend/assets/index-BH30iLzb.css +0 -2
- package/dist/frontend/assets/index-sBFt3ORj.js +0 -84
|
@@ -5,17 +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
|
|
10
11
|
from dataclasses import dataclass
|
|
11
12
|
from importlib import import_module
|
|
12
|
-
from typing import Any, Protocol
|
|
13
|
+
from typing import Any, Protocol, TextIO, cast
|
|
13
14
|
|
|
14
15
|
from flowent.storage import StateStore, StoredMcpServer, StoredMcpTool
|
|
15
16
|
from flowent.tools import ToolResult
|
|
16
17
|
|
|
17
18
|
logger = logging.getLogger("flowent.mcp")
|
|
18
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
|
+
)
|
|
19
25
|
|
|
20
26
|
|
|
21
27
|
class McpTransport(Protocol):
|
|
@@ -128,6 +134,96 @@ class _McpConnection:
|
|
|
128
134
|
session: Any = None
|
|
129
135
|
|
|
130
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
|
+
|
|
131
227
|
class DefaultMcpTransport:
|
|
132
228
|
def __init__(self) -> None:
|
|
133
229
|
self._connections: dict[str, _McpConnection] = {}
|
|
@@ -197,9 +293,11 @@ class DefaultMcpTransport:
|
|
|
197
293
|
)
|
|
198
294
|
)
|
|
199
295
|
else:
|
|
296
|
+
stdio_errlog = await stack.enter_async_context(_McpStdioErrorLog())
|
|
200
297
|
read_stream, write_stream = await stack.enter_async_context(
|
|
201
298
|
stdio_client(
|
|
202
299
|
self._stdio_parameters(server, config),
|
|
300
|
+
errlog=cast(TextIO, stdio_errlog),
|
|
203
301
|
)
|
|
204
302
|
)
|
|
205
303
|
session = await stack.enter_async_context(
|
|
@@ -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
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI, Query
|
|
4
|
+
from fastapi.responses import StreamingResponse
|
|
5
|
+
|
|
6
|
+
from flowent.api_models import (
|
|
7
|
+
WorkspaceClearResponse,
|
|
8
|
+
WorkspaceMessageEditRequest,
|
|
9
|
+
WorkspaceMessageEditResponse,
|
|
10
|
+
WorkspaceMessagesRequest,
|
|
11
|
+
WorkspaceRespondRequest,
|
|
12
|
+
WorkspaceRunResponse,
|
|
13
|
+
)
|
|
14
|
+
from flowent.logging import TRACE_LEVEL
|
|
15
|
+
from flowent.storage import StateStore
|
|
16
|
+
from flowent.workspace.runtime import WorkspaceRuntime
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("flowent.routes.workspace")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def register_workspace_routes(
|
|
22
|
+
app: FastAPI,
|
|
23
|
+
*,
|
|
24
|
+
runtime: WorkspaceRuntime,
|
|
25
|
+
store: StateStore,
|
|
26
|
+
) -> None:
|
|
27
|
+
@app.put("/api/workspace/messages")
|
|
28
|
+
async def save_workspace_messages(
|
|
29
|
+
request: WorkspaceMessagesRequest,
|
|
30
|
+
) -> WorkspaceMessagesRequest:
|
|
31
|
+
return WorkspaceMessagesRequest(messages=store.save_messages(request.messages))
|
|
32
|
+
|
|
33
|
+
@app.post("/api/workspace/messages/{message_id}/edit")
|
|
34
|
+
async def edit_workspace_message(
|
|
35
|
+
message_id: str,
|
|
36
|
+
request: WorkspaceMessageEditRequest,
|
|
37
|
+
) -> WorkspaceMessageEditResponse:
|
|
38
|
+
logger.info(
|
|
39
|
+
"Workspace message edit requested action=%s message_id=%s content_length=%s",
|
|
40
|
+
request.action,
|
|
41
|
+
message_id,
|
|
42
|
+
len(request.content),
|
|
43
|
+
)
|
|
44
|
+
logger.log(TRACE_LEVEL, "Workspace edited user content=%r", request.content)
|
|
45
|
+
messages, run = runtime.edit_message(
|
|
46
|
+
message_id,
|
|
47
|
+
action=request.action,
|
|
48
|
+
content=request.content,
|
|
49
|
+
)
|
|
50
|
+
return WorkspaceMessageEditResponse(
|
|
51
|
+
messages=messages,
|
|
52
|
+
run_id=run.id if run else None,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
@app.post("/api/workspace/clear")
|
|
56
|
+
async def clear_workspace() -> WorkspaceClearResponse:
|
|
57
|
+
messages = runtime.clear()
|
|
58
|
+
await runtime.notify_cleared_runs()
|
|
59
|
+
return WorkspaceClearResponse(messages=messages)
|
|
60
|
+
|
|
61
|
+
@app.post("/api/workspace/runs")
|
|
62
|
+
async def start_workspace_run(
|
|
63
|
+
request: WorkspaceRespondRequest,
|
|
64
|
+
) -> WorkspaceRunResponse:
|
|
65
|
+
logger.info("Workspace run requested content_length=%s", len(request.content))
|
|
66
|
+
logger.log(TRACE_LEVEL, "Workspace user content=%r", request.content)
|
|
67
|
+
run = runtime.create_run(request.content, message_id=request.message_id)
|
|
68
|
+
return WorkspaceRunResponse(run_id=run.id)
|
|
69
|
+
|
|
70
|
+
@app.get("/api/workspace/runs/{run_id}/stream")
|
|
71
|
+
async def stream_workspace_run(
|
|
72
|
+
run_id: str,
|
|
73
|
+
after: int = Query(default=0, ge=0),
|
|
74
|
+
) -> StreamingResponse:
|
|
75
|
+
run = runtime.run_by_id(run_id)
|
|
76
|
+
return StreamingResponse(
|
|
77
|
+
runtime.run_stream(run, after),
|
|
78
|
+
media_type="text/event-stream",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
@app.post("/api/workspace/runs/{run_id}/stop")
|
|
82
|
+
async def stop_workspace_run(run_id: str) -> dict[str, bool]:
|
|
83
|
+
runtime.stop_run(run_id)
|
|
84
|
+
return {"ok": True}
|
|
85
|
+
|
|
86
|
+
@app.post("/api/workspace/compact", response_class=StreamingResponse)
|
|
87
|
+
async def compact_workspace() -> StreamingResponse:
|
|
88
|
+
return StreamingResponse(
|
|
89
|
+
runtime.compact_stream(),
|
|
90
|
+
media_type="text/event-stream",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
@app.post("/api/workspace/respond")
|
|
94
|
+
async def respond_to_workspace(
|
|
95
|
+
request: WorkspaceRespondRequest,
|
|
96
|
+
) -> StreamingResponse:
|
|
97
|
+
logger.info(
|
|
98
|
+
"Workspace response requested content_length=%s", len(request.content)
|
|
99
|
+
)
|
|
100
|
+
logger.log(TRACE_LEVEL, "Workspace user content=%r", request.content)
|
|
101
|
+
run = runtime.create_run(request.content, message_id=request.message_id)
|
|
102
|
+
return StreamingResponse(
|
|
103
|
+
runtime.run_stream(run, include_snapshots=False),
|
|
104
|
+
media_type="text/event-stream",
|
|
105
|
+
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from flowent.state.models import (
|
|
2
|
+
StoredAssistantOutputGroup,
|
|
3
|
+
StoredCompactionCheckpoint,
|
|
4
|
+
StoredErrorOutputItem,
|
|
5
|
+
StoredMcpServer,
|
|
6
|
+
StoredMcpTool,
|
|
7
|
+
StoredMessage,
|
|
8
|
+
StoredOutputItem,
|
|
9
|
+
StoredProvider,
|
|
10
|
+
StoredSettings,
|
|
11
|
+
StoredSkill,
|
|
12
|
+
StoredState,
|
|
13
|
+
StoredTelegramBot,
|
|
14
|
+
StoredTelegramSession,
|
|
15
|
+
StoredTextOutputItem,
|
|
16
|
+
StoredThinkingOutputItem,
|
|
17
|
+
StoredToolItem,
|
|
18
|
+
StoredToolOutputItem,
|
|
19
|
+
StoredWorkflow,
|
|
20
|
+
StoredWorkflowDefinition,
|
|
21
|
+
StoredWorkflowEdge,
|
|
22
|
+
StoredWorkflowNode,
|
|
23
|
+
StoredWorkflowNodePosition,
|
|
24
|
+
StoredWritablePath,
|
|
25
|
+
)
|
|
26
|
+
from flowent.state.store import StateStore
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"StateStore",
|
|
30
|
+
"StoredAssistantOutputGroup",
|
|
31
|
+
"StoredCompactionCheckpoint",
|
|
32
|
+
"StoredErrorOutputItem",
|
|
33
|
+
"StoredMcpServer",
|
|
34
|
+
"StoredMcpTool",
|
|
35
|
+
"StoredMessage",
|
|
36
|
+
"StoredOutputItem",
|
|
37
|
+
"StoredProvider",
|
|
38
|
+
"StoredSettings",
|
|
39
|
+
"StoredSkill",
|
|
40
|
+
"StoredState",
|
|
41
|
+
"StoredTelegramBot",
|
|
42
|
+
"StoredTelegramSession",
|
|
43
|
+
"StoredTextOutputItem",
|
|
44
|
+
"StoredThinkingOutputItem",
|
|
45
|
+
"StoredToolItem",
|
|
46
|
+
"StoredToolOutputItem",
|
|
47
|
+
"StoredWorkflow",
|
|
48
|
+
"StoredWorkflowDefinition",
|
|
49
|
+
"StoredWorkflowEdge",
|
|
50
|
+
"StoredWorkflowNode",
|
|
51
|
+
"StoredWorkflowNodePosition",
|
|
52
|
+
"StoredWritablePath",
|
|
53
|
+
]
|