flowent 0.2.4 → 0.3.1
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 +108 -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 +52 -6
- 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 +53 -0
- package/backend/src/flowent/routes/system.py +48 -0
- package/backend/src/flowent/routes/workflow_routes.py +63 -0
- package/backend/src/flowent/routes/workspace.py +115 -0
- package/backend/src/flowent/state/__init__.py +53 -0
- package/backend/src/flowent/state/models.py +258 -0
- package/backend/src/flowent/state/schema.py +191 -0
- package/backend/src/flowent/state/store.py +1019 -0
- package/backend/src/flowent/static/assets/index-BaZmIi2Y.js +98 -0
- package/backend/src/flowent/static/assets/index-EC37agAH.css +2 -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 +335 -0
- package/backend/src/flowent/workspace/events.py +178 -0
- package/backend/src/flowent/workspace/output.py +396 -0
- package/backend/src/flowent/workspace/runtime.py +1160 -0
- package/backend/uv.lock +1 -1
- package/dist/frontend/assets/index-BaZmIi2Y.js +98 -0
- package/dist/frontend/assets/index-EC37agAH.css +2 -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
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
|
|
5
|
+
from fastapi import HTTPException
|
|
6
|
+
|
|
7
|
+
from flowent.compact import transcript_messages_after
|
|
8
|
+
from flowent.llm import ChatMessage
|
|
9
|
+
from flowent.storage import (
|
|
10
|
+
StoredCompactionCheckpoint,
|
|
11
|
+
StoredMessage,
|
|
12
|
+
StoredSettings,
|
|
13
|
+
StoredState,
|
|
14
|
+
)
|
|
15
|
+
from flowent.usage import (
|
|
16
|
+
TokenUsageInfo,
|
|
17
|
+
current_model_context_window,
|
|
18
|
+
estimated_token_usage_for_messages,
|
|
19
|
+
recompute_context_usage,
|
|
20
|
+
)
|
|
21
|
+
from flowent.workspace.output import error_context_summary, message_error_items
|
|
22
|
+
|
|
23
|
+
COMPACTED_CONTEXT_MARKER = "Context compacted"
|
|
24
|
+
OPTIMIZED_CONTEXT_MARKER = "Context optimized"
|
|
25
|
+
DEFAULT_AUTO_COMPACT_CONTEXT_WINDOW_RATIO = 0.95
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def latest_compacted_context_index(messages: list[StoredMessage]) -> int | None:
|
|
29
|
+
for index in range(len(messages) - 1, -1, -1):
|
|
30
|
+
message = messages[index]
|
|
31
|
+
if message.author == "system" and is_context_marker(message):
|
|
32
|
+
return index
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_context_marker(message: StoredMessage) -> bool:
|
|
37
|
+
return message.content in {COMPACTED_CONTEXT_MARKER, OPTIMIZED_CONTEXT_MARKER}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def auto_compact_token_limit(context_window: int) -> int:
|
|
41
|
+
raw_limit = os.environ.get("FLOWENT_AUTO_COMPACT_TOKEN_LIMIT", "")
|
|
42
|
+
if not raw_limit:
|
|
43
|
+
return max(0, int(context_window * DEFAULT_AUTO_COMPACT_CONTEXT_WINDOW_RATIO))
|
|
44
|
+
try:
|
|
45
|
+
return max(0, int(raw_limit))
|
|
46
|
+
except ValueError:
|
|
47
|
+
return max(0, int(context_window * DEFAULT_AUTO_COMPACT_CONTEXT_WINDOW_RATIO))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def should_auto_compact(
|
|
51
|
+
messages: Sequence[ChatMessage | Mapping[str, object]],
|
|
52
|
+
*,
|
|
53
|
+
context_window: int,
|
|
54
|
+
) -> bool:
|
|
55
|
+
token_limit = auto_compact_token_limit(context_window)
|
|
56
|
+
if token_limit <= 0:
|
|
57
|
+
return False
|
|
58
|
+
return (
|
|
59
|
+
estimated_token_usage_for_messages(
|
|
60
|
+
model_request_messages_data(messages)
|
|
61
|
+
).total_tokens
|
|
62
|
+
>= token_limit
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def model_visible_messages_for_usage(
|
|
67
|
+
messages: Sequence[Mapping[str, object]],
|
|
68
|
+
) -> list[dict[str, object]]:
|
|
69
|
+
return [
|
|
70
|
+
dict(message)
|
|
71
|
+
for message in messages
|
|
72
|
+
if message.get("role") in {"system", "user", "assistant", "tool"}
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def usage_event_data(usage_info: TokenUsageInfo) -> dict[str, object]:
|
|
77
|
+
return {"usage_info": usage_info.model_dump()}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def update_context_usage_for_response(
|
|
81
|
+
usage_info: TokenUsageInfo | None,
|
|
82
|
+
*,
|
|
83
|
+
messages: Sequence[Mapping[str, object]],
|
|
84
|
+
output_content: str,
|
|
85
|
+
output_tools: Sequence[Mapping[str, object]] = (),
|
|
86
|
+
model_context_window: int,
|
|
87
|
+
) -> TokenUsageInfo:
|
|
88
|
+
return recompute_context_usage(
|
|
89
|
+
usage_info,
|
|
90
|
+
estimated_token_usage_for_messages(
|
|
91
|
+
[
|
|
92
|
+
*model_visible_messages_for_usage(messages),
|
|
93
|
+
*model_visible_response_messages_for_usage(
|
|
94
|
+
output_content, output_tools
|
|
95
|
+
),
|
|
96
|
+
],
|
|
97
|
+
).total_tokens,
|
|
98
|
+
model_context_window=model_context_window,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def model_visible_response_messages_for_usage(
|
|
103
|
+
output_content: str,
|
|
104
|
+
output_tools: Sequence[Mapping[str, object]],
|
|
105
|
+
) -> list[dict[str, object]]:
|
|
106
|
+
visible_messages: list[dict[str, object]] = []
|
|
107
|
+
for index, tool in enumerate(output_tools):
|
|
108
|
+
tool_id = str(tool.get("id") or f"call_{index}")
|
|
109
|
+
arguments = tool.get("arguments")
|
|
110
|
+
visible_messages.append(
|
|
111
|
+
{
|
|
112
|
+
"role": "assistant",
|
|
113
|
+
"content": "",
|
|
114
|
+
"tool_calls": [
|
|
115
|
+
{
|
|
116
|
+
"id": tool_id,
|
|
117
|
+
"type": "function",
|
|
118
|
+
"function": {
|
|
119
|
+
"name": str(tool.get("name") or ""),
|
|
120
|
+
"arguments": json.dumps(
|
|
121
|
+
arguments if arguments is not None else {},
|
|
122
|
+
ensure_ascii=False,
|
|
123
|
+
),
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
],
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
visible_messages.append(
|
|
130
|
+
{
|
|
131
|
+
"role": "tool",
|
|
132
|
+
"tool_call_id": tool_id,
|
|
133
|
+
"content": str(tool.get("content") or ""),
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
if output_content:
|
|
137
|
+
visible_messages.append({"role": "assistant", "content": output_content})
|
|
138
|
+
return visible_messages
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def model_visible_assistant_output_messages(
|
|
142
|
+
message: StoredMessage,
|
|
143
|
+
) -> list[dict[str, object]]:
|
|
144
|
+
visible_messages: list[dict[str, object]] = []
|
|
145
|
+
for group in message.groups:
|
|
146
|
+
group_content = "".join(
|
|
147
|
+
item.content for item in group.items if item.type == "text"
|
|
148
|
+
)
|
|
149
|
+
group_tools = [item.tool for item in group.items if item.type == "tool"]
|
|
150
|
+
if not group_tools:
|
|
151
|
+
if group_content:
|
|
152
|
+
visible_messages.append({"role": "assistant", "content": group_content})
|
|
153
|
+
continue
|
|
154
|
+
visible_messages.append(
|
|
155
|
+
{
|
|
156
|
+
"role": "assistant",
|
|
157
|
+
"content": group_content or None,
|
|
158
|
+
"tool_calls": [
|
|
159
|
+
{
|
|
160
|
+
"id": tool.id,
|
|
161
|
+
"type": "function",
|
|
162
|
+
"function": {
|
|
163
|
+
"name": tool.name,
|
|
164
|
+
"arguments": json.dumps(
|
|
165
|
+
tool.arguments or {},
|
|
166
|
+
ensure_ascii=False,
|
|
167
|
+
),
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
for tool in group_tools
|
|
171
|
+
],
|
|
172
|
+
}
|
|
173
|
+
)
|
|
174
|
+
visible_messages.extend(
|
|
175
|
+
{
|
|
176
|
+
"role": "tool",
|
|
177
|
+
"tool_call_id": tool.id,
|
|
178
|
+
"content": tool.content or "",
|
|
179
|
+
}
|
|
180
|
+
for tool in group_tools
|
|
181
|
+
if tool.status != "running"
|
|
182
|
+
)
|
|
183
|
+
if not visible_messages and message.content:
|
|
184
|
+
visible_messages.append({"role": "assistant", "content": message.content})
|
|
185
|
+
return visible_messages
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def model_visible_workspace_message(message: StoredMessage) -> list[dict[str, object]]:
|
|
189
|
+
if message.author == "user":
|
|
190
|
+
return [{"role": "user", "content": message.content}]
|
|
191
|
+
if message.author != "assistant":
|
|
192
|
+
raise HTTPException(status_code=400, detail="Message history is invalid.")
|
|
193
|
+
errors = message_error_items(message)
|
|
194
|
+
if errors:
|
|
195
|
+
return [
|
|
196
|
+
{"role": "assistant", "content": error_context_summary(error)}
|
|
197
|
+
for error in errors
|
|
198
|
+
]
|
|
199
|
+
return model_visible_assistant_output_messages(message)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def model_visible_workspace_messages(
|
|
203
|
+
messages: Sequence[StoredMessage],
|
|
204
|
+
) -> list[dict[str, object]]:
|
|
205
|
+
visible_messages: list[dict[str, object]] = []
|
|
206
|
+
for message in messages:
|
|
207
|
+
visible_messages.extend(model_visible_workspace_message(message))
|
|
208
|
+
return visible_messages
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def compact_prompt_chat_message(message: Mapping[str, object]) -> ChatMessage:
|
|
212
|
+
role_value = message.get("role")
|
|
213
|
+
content = str(message.get("content") or "")
|
|
214
|
+
if role_value == "system":
|
|
215
|
+
return ChatMessage(role="system", content=content)
|
|
216
|
+
if role_value == "assistant":
|
|
217
|
+
tool_calls = message.get("tool_calls")
|
|
218
|
+
if tool_calls:
|
|
219
|
+
return ChatMessage(
|
|
220
|
+
role="assistant",
|
|
221
|
+
content=(
|
|
222
|
+
f"Tool call: {json.dumps(tool_calls, ensure_ascii=False)}"
|
|
223
|
+
if not content
|
|
224
|
+
else f"{content}\nTool call: {json.dumps(tool_calls, ensure_ascii=False)}"
|
|
225
|
+
),
|
|
226
|
+
)
|
|
227
|
+
return ChatMessage(role="assistant", content=content)
|
|
228
|
+
if role_value == "tool":
|
|
229
|
+
return ChatMessage(role="user", content=f"Tool result: {content}")
|
|
230
|
+
return ChatMessage(role="user", content=content)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def model_request_message_data(
|
|
234
|
+
message: ChatMessage | Mapping[str, object],
|
|
235
|
+
) -> dict[str, object]:
|
|
236
|
+
if isinstance(message, ChatMessage):
|
|
237
|
+
return message.model_dump()
|
|
238
|
+
return dict(message)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def model_request_messages_data(
|
|
242
|
+
messages: Sequence[ChatMessage | Mapping[str, object]],
|
|
243
|
+
) -> list[dict[str, object]]:
|
|
244
|
+
return [model_request_message_data(message) for message in messages]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def compact_prompt_chat_messages(
|
|
248
|
+
messages: Sequence[ChatMessage | Mapping[str, object]],
|
|
249
|
+
) -> list[ChatMessage]:
|
|
250
|
+
return [
|
|
251
|
+
message
|
|
252
|
+
if isinstance(message, ChatMessage)
|
|
253
|
+
else compact_prompt_chat_message(message)
|
|
254
|
+
for message in messages
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def usage_info_for_model(
|
|
259
|
+
usage_info: TokenUsageInfo | None,
|
|
260
|
+
model_context_window: int,
|
|
261
|
+
) -> TokenUsageInfo | None:
|
|
262
|
+
if usage_info is None:
|
|
263
|
+
return None
|
|
264
|
+
return usage_info.model_copy(update={"model_context_window": model_context_window})
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def context_window_for_settings(settings: StoredSettings) -> int:
|
|
268
|
+
if settings.context_window_limit is not None:
|
|
269
|
+
return settings.context_window_limit
|
|
270
|
+
return current_model_context_window(settings.selected_model)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def state_with_current_model_context_window(state: StoredState) -> StoredState:
|
|
274
|
+
model_context_window = context_window_for_settings(state.settings)
|
|
275
|
+
return state.model_copy(
|
|
276
|
+
update={
|
|
277
|
+
"messages": [
|
|
278
|
+
message.model_copy(
|
|
279
|
+
update={
|
|
280
|
+
"usage_info": usage_info_for_model(
|
|
281
|
+
message.usage_info,
|
|
282
|
+
model_context_window,
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
)
|
|
286
|
+
if message.usage_info is not None
|
|
287
|
+
else message
|
|
288
|
+
for message in state.messages
|
|
289
|
+
],
|
|
290
|
+
"usage_info": usage_info_for_model(
|
|
291
|
+
state.usage_info,
|
|
292
|
+
model_context_window,
|
|
293
|
+
),
|
|
294
|
+
}
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def workspace_chat_messages(
|
|
299
|
+
messages: list[StoredMessage],
|
|
300
|
+
compacted_context: str = "",
|
|
301
|
+
checkpoint: StoredCompactionCheckpoint | None = None,
|
|
302
|
+
) -> list[dict[str, object]]:
|
|
303
|
+
chat_messages: list[dict[str, object]] = []
|
|
304
|
+
|
|
305
|
+
if checkpoint is not None:
|
|
306
|
+
chat_messages.extend(
|
|
307
|
+
model_request_messages_data(checkpoint.replacement_history)
|
|
308
|
+
)
|
|
309
|
+
visible_messages = transcript_messages_after(
|
|
310
|
+
messages,
|
|
311
|
+
checkpoint.source_message_id,
|
|
312
|
+
)
|
|
313
|
+
for message in visible_messages:
|
|
314
|
+
if message.author == "system" and is_context_marker(message):
|
|
315
|
+
continue
|
|
316
|
+
chat_messages.extend(model_visible_workspace_message(message))
|
|
317
|
+
return chat_messages
|
|
318
|
+
|
|
319
|
+
marker_index = latest_compacted_context_index(messages)
|
|
320
|
+
visible_messages = messages
|
|
321
|
+
|
|
322
|
+
if compacted_context and marker_index is not None:
|
|
323
|
+
chat_messages.extend(
|
|
324
|
+
[
|
|
325
|
+
{"role": "user", "content": COMPACTED_CONTEXT_MARKER},
|
|
326
|
+
{"role": "assistant", "content": compacted_context},
|
|
327
|
+
]
|
|
328
|
+
)
|
|
329
|
+
visible_messages = messages[marker_index + 1 :]
|
|
330
|
+
|
|
331
|
+
for message in visible_messages:
|
|
332
|
+
if message.author == "system" and is_context_marker(message):
|
|
333
|
+
continue
|
|
334
|
+
chat_messages.extend(model_visible_workspace_message(message))
|
|
335
|
+
return chat_messages
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import copy
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from flowent.storage import StoredMessage
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class WorkspaceResponse:
|
|
12
|
+
condition: asyncio.Condition
|
|
13
|
+
active_output: Literal["text", "thinking"] | None = None
|
|
14
|
+
discard_on_cancel: bool = False
|
|
15
|
+
events: list[tuple[int, str, dict[str, object]]] = field(default_factory=list)
|
|
16
|
+
generation: int = 0
|
|
17
|
+
is_done: bool = False
|
|
18
|
+
latest_snapshot: StoredMessage | None = None
|
|
19
|
+
task: asyncio.Task[None] | None = None
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def latest_event_index(self) -> int:
|
|
23
|
+
return self.events[-1][0] if self.events else 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def stream_event(
|
|
27
|
+
event: str, data: dict[str, object], event_id: int | None = None
|
|
28
|
+
) -> str:
|
|
29
|
+
id_line = f"id: {event_id}\n" if event_id is not None else ""
|
|
30
|
+
return f"{id_line}event: {event}\ndata: {json.dumps(data)}\n\n"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def stream_message_data(
|
|
34
|
+
message: StoredMessage, active_output: Literal["text", "thinking"] | None = None
|
|
35
|
+
) -> dict[str, object]:
|
|
36
|
+
data = {**message.model_dump(), "status": message.status}
|
|
37
|
+
if active_output is not None:
|
|
38
|
+
data["active_output"] = active_output
|
|
39
|
+
return data
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def append_or_replace_message(
|
|
43
|
+
messages: list[StoredMessage], message: StoredMessage
|
|
44
|
+
) -> list[StoredMessage]:
|
|
45
|
+
return [
|
|
46
|
+
*(current for current in messages if current.id != message.id),
|
|
47
|
+
message,
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def response_snapshot_data_at(
|
|
52
|
+
response: WorkspaceResponse, event_index: int
|
|
53
|
+
) -> dict[str, object] | None:
|
|
54
|
+
snapshot_event_index = 0
|
|
55
|
+
snapshot: dict[str, object] | None = None
|
|
56
|
+
for current_event_index, event, data in response.events:
|
|
57
|
+
if current_event_index > event_index:
|
|
58
|
+
break
|
|
59
|
+
if event != "snapshot":
|
|
60
|
+
if event == "start" and snapshot is None:
|
|
61
|
+
assistant_id = data.get("id")
|
|
62
|
+
if isinstance(assistant_id, str):
|
|
63
|
+
snapshot_event_index = current_event_index
|
|
64
|
+
snapshot = {
|
|
65
|
+
"author": "assistant",
|
|
66
|
+
"content": "",
|
|
67
|
+
"groups": [],
|
|
68
|
+
"id": assistant_id,
|
|
69
|
+
"status": "running",
|
|
70
|
+
"tools": [],
|
|
71
|
+
}
|
|
72
|
+
continue
|
|
73
|
+
message = data.get("message")
|
|
74
|
+
if isinstance(message, dict):
|
|
75
|
+
snapshot_event_index = current_event_index
|
|
76
|
+
snapshot = copy.deepcopy(message)
|
|
77
|
+
if snapshot is None:
|
|
78
|
+
return None
|
|
79
|
+
for current_event_index, event, data in response.events:
|
|
80
|
+
if current_event_index <= snapshot_event_index:
|
|
81
|
+
continue
|
|
82
|
+
if current_event_index > event_index:
|
|
83
|
+
break
|
|
84
|
+
apply_stream_event_to_snapshot(snapshot, event, data)
|
|
85
|
+
return snapshot
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def apply_stream_event_to_snapshot(
|
|
89
|
+
snapshot: dict[str, object], event: str, data: dict[str, object]
|
|
90
|
+
) -> None:
|
|
91
|
+
if event == "output_start":
|
|
92
|
+
snapshot.pop("active_output", None)
|
|
93
|
+
index = data.get("index")
|
|
94
|
+
if isinstance(index, int):
|
|
95
|
+
append_snapshot_group(snapshot, index)
|
|
96
|
+
if event == "delta":
|
|
97
|
+
append_snapshot_text(snapshot, str(data.get("content") or ""))
|
|
98
|
+
if event == "thinking_delta":
|
|
99
|
+
append_snapshot_thinking(snapshot, str(data.get("content") or ""))
|
|
100
|
+
if event == "output_done":
|
|
101
|
+
snapshot.pop("active_output", None)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def snapshot_groups(snapshot: dict[str, object]) -> list[dict[str, object]]:
|
|
105
|
+
groups = snapshot.get("groups")
|
|
106
|
+
if not isinstance(groups, list):
|
|
107
|
+
groups = []
|
|
108
|
+
snapshot["groups"] = groups
|
|
109
|
+
return groups
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def append_snapshot_group(
|
|
113
|
+
snapshot: dict[str, object], index: int | None = None
|
|
114
|
+
) -> None:
|
|
115
|
+
groups = snapshot_groups(snapshot)
|
|
116
|
+
assistant_id = str(snapshot.get("id") or "assistant")
|
|
117
|
+
group_index = index if index is not None else len(groups) + 1
|
|
118
|
+
group_id = f"{assistant_id}-group-{group_index}"
|
|
119
|
+
if groups and groups[-1].get("id") == group_id:
|
|
120
|
+
return
|
|
121
|
+
groups.append({"id": group_id, "items": []})
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def append_snapshot_text(snapshot: dict[str, object], content: str) -> None:
|
|
125
|
+
if not content:
|
|
126
|
+
return
|
|
127
|
+
snapshot["active_output"] = "text"
|
|
128
|
+
snapshot["content"] = f"{snapshot.get('content') or ''}{content}"
|
|
129
|
+
append_snapshot_item_content(snapshot, content, "text")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def append_snapshot_thinking(snapshot: dict[str, object], content: str) -> None:
|
|
133
|
+
if not content:
|
|
134
|
+
return
|
|
135
|
+
snapshot["active_output"] = "thinking"
|
|
136
|
+
snapshot["thinking"] = f"{snapshot.get('thinking') or ''}{content}"
|
|
137
|
+
append_snapshot_item_content(snapshot, content, "thinking")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def append_snapshot_item_content(
|
|
141
|
+
snapshot: dict[str, object], content: str, item_type: Literal["text", "thinking"]
|
|
142
|
+
) -> None:
|
|
143
|
+
groups = snapshot_groups(snapshot)
|
|
144
|
+
if not groups:
|
|
145
|
+
append_snapshot_group(snapshot)
|
|
146
|
+
group = groups[-1]
|
|
147
|
+
items = group.get("items")
|
|
148
|
+
if not isinstance(items, list):
|
|
149
|
+
items = []
|
|
150
|
+
group["items"] = items
|
|
151
|
+
item = next(
|
|
152
|
+
(
|
|
153
|
+
current
|
|
154
|
+
for current in reversed(items)
|
|
155
|
+
if isinstance(current, dict) and current.get("type") == item_type
|
|
156
|
+
),
|
|
157
|
+
None,
|
|
158
|
+
)
|
|
159
|
+
if item is None:
|
|
160
|
+
assistant_id = str(snapshot.get("id") or "assistant")
|
|
161
|
+
snapshot_item_count = 0
|
|
162
|
+
for current_group in groups:
|
|
163
|
+
current_items = current_group.get("items")
|
|
164
|
+
if not isinstance(current_items, list):
|
|
165
|
+
continue
|
|
166
|
+
snapshot_item_count += sum(
|
|
167
|
+
1
|
|
168
|
+
for current_item in current_items
|
|
169
|
+
if isinstance(current_item, dict)
|
|
170
|
+
and current_item.get("type") == item_type
|
|
171
|
+
)
|
|
172
|
+
item = {
|
|
173
|
+
"content": "",
|
|
174
|
+
"id": f"{assistant_id}-{item_type}-{snapshot_item_count + 1}",
|
|
175
|
+
"type": item_type,
|
|
176
|
+
}
|
|
177
|
+
items.append(item)
|
|
178
|
+
item["content"] = f"{item.get('content') or ''}{content}"
|