cansik commited on
Commit
691f45a
·
verified ·
1 Parent(s): 0355ac5

Upload folder via script

Browse files
.gitignore CHANGED
@@ -222,3 +222,4 @@ __marimo__/
222
  .DS_Store
223
  llm_context.txt
224
  generate_txt.py
 
 
222
  .DS_Store
223
  llm_context.txt
224
  generate_txt.py
225
+ .nicegui
dataflow/codecs.py CHANGED
@@ -1,6 +1,8 @@
1
  from __future__ import annotations
 
2
  from typing import Any
3
 
 
4
  from .nodes_base import NodeInstance
5
  from .ports import PortSchema
6
 
@@ -28,6 +30,22 @@ def node_to_vueflow(n: NodeInstance, data_extra: dict[str, Any] | None = None) -
28
  }
29
 
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  def _schema_json(p: PortSchema) -> dict[str, Any]:
32
  return {
33
  "name": p.name,
@@ -35,4 +53,5 @@ def _schema_json(p: PortSchema) -> dict[str, Any]:
35
  "direction": p.direction.value,
36
  "multiplicity": p.multiplicity.value,
37
  "capacity": p.capacity,
 
38
  }
 
1
  from __future__ import annotations
2
+
3
  from typing import Any
4
 
5
+ from .connection import Connection
6
  from .nodes_base import NodeInstance
7
  from .ports import PortSchema
8
 
 
30
  }
31
 
32
 
33
+ def connection_to_vueflow_edge(c: Connection) -> dict[str, Any]:
34
+ """Convert a Connection object to a Vue Flow edge dict."""
35
+ source_handle = f"{c.start_node.node_id}:{c.start_port.name}"
36
+ target_handle = f"{c.end_node.node_id}:{c.end_port.name}"
37
+
38
+ edge_id = f"{c.start_node.node_id}:{c.start_port.name}->{c.end_node.node_id}:{c.end_port.name}"
39
+
40
+ return {
41
+ "id": edge_id,
42
+ "source": c.start_node.node_id,
43
+ "target": c.end_node.node_id,
44
+ "sourceHandle": source_handle,
45
+ "targetHandle": target_handle,
46
+ }
47
+
48
+
49
  def _schema_json(p: PortSchema) -> dict[str, Any]:
50
  return {
51
  "name": p.name,
 
53
  "direction": p.direction.value,
54
  "multiplicity": p.multiplicity.value,
55
  "capacity": p.capacity,
56
+ "color": p.dtype.color,
57
  }
dataflow/enums.py CHANGED
@@ -25,4 +25,5 @@ class DataTypeId(Enum):
25
 
26
  class NodeKind(Enum):
27
  TEXT_DATA = "TextDataNode"
 
28
  TEXT_TO_IMAGE = "TextToImageNode"
 
25
 
26
  class NodeKind(Enum):
27
  TEXT_DATA = "TextDataNode"
28
+ IMAGE_DATA = "ImageDataNode"
29
  TEXT_TO_IMAGE = "TextToImageNode"
dataflow/graph.py CHANGED
@@ -1,9 +1,11 @@
1
  from __future__ import annotations
 
2
  from dataclasses import dataclass, field
3
  from typing import Iterable
4
- from .nodes_base import NodeInstance
5
  from .connection import Connection
6
- from .enums import DataPortState
 
7
 
8
 
9
  @dataclass(slots=True)
@@ -32,24 +34,50 @@ class DataGraph:
32
  if c.start_node is node:
33
  yield c
34
 
35
- def execute(self, node: NodeInstance | None = None) -> None:
36
  if node is None:
37
  for n in list(self.nodes.values()):
38
- self.execute(n)
39
  return
40
 
41
- # ensure inputs are up to date
 
 
 
 
 
 
 
 
42
  for inp in node.all_inputs():
43
- for c in self.upstream_of(node):
44
- if c.end_port is inp and c.start_port.state is DataPortState.DIRTY:
45
- self.execute(c.start_node)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
 
47
- node.process()
48
  for outp in node.all_outputs():
49
  outp.state = DataPortState.CLEAN
50
 
51
  for c in self.downstream_of(node):
52
- c.end_port.value = c.start_port.value
53
  c.end_port.state = DataPortState.DIRTY
54
  if c.end_node.auto_process:
55
- self.execute(c.end_node)
 
1
  from __future__ import annotations
2
+
3
  from dataclasses import dataclass, field
4
  from typing import Iterable
5
+
6
  from .connection import Connection
7
+ from .enums import DataPortState, ConnectMultiplicity
8
+ from .nodes_base import NodeInstance
9
 
10
 
11
  @dataclass(slots=True)
 
34
  if c.start_node is node:
35
  yield c
36
 
37
+ async def execute(self, node: NodeInstance | None = None) -> None:
38
  if node is None:
39
  for n in list(self.nodes.values()):
40
+ await self.execute(n)
41
  return
42
 
43
+ # Identify incoming connections
44
+ incoming_conns = list(self.upstream_of(node))
45
+
46
+ # Recursively execute upstream if dirty
47
+ for c in incoming_conns:
48
+ if c.start_port.state == DataPortState.DIRTY:
49
+ await self.execute(c.start_node)
50
+
51
+ # Pull values from upstream to inputs
52
  for inp in node.all_inputs():
53
+ # Find all connections feeding this port
54
+ feeds = [c for c in incoming_conns if c.end_port is inp]
55
+
56
+ if not feeds:
57
+ continue
58
+
59
+ if inp.schema.multiplicity == ConnectMultiplicity.MULTIPLE:
60
+ # Collect all values
61
+ values = []
62
+ for c in feeds:
63
+ if c.start_port.value is not None:
64
+ values.append(c.start_port.value)
65
+ inp.value = values
66
+ else:
67
+ # Single connection (take the last one if multiple defined by mistake)
68
+ if feeds:
69
+ inp.value = feeds[-1].start_port.value
70
+
71
+ inp.state = DataPortState.CLEAN
72
+
73
+ # Process
74
+ await node.process()
75
 
 
76
  for outp in node.all_outputs():
77
  outp.state = DataPortState.CLEAN
78
 
79
  for c in self.downstream_of(node):
80
+ # We just mark downstream as dirty; the next execute call will pull the data
81
  c.end_port.state = DataPortState.DIRTY
82
  if c.end_node.auto_process:
83
+ await self.execute(c.end_node)
dataflow/nodes_base.py CHANGED
@@ -1,8 +1,10 @@
1
  from __future__ import annotations
 
2
  from dataclasses import dataclass, field
3
  from typing import Iterable, Callable
4
- from .ports import PortSchema, PortState
5
  from .enums import NodeKind, DataPortState
 
6
 
7
 
8
  @dataclass(slots=True)
@@ -42,8 +44,13 @@ class NodeInstance:
42
  for p in self.all_outputs():
43
  p.state = DataPortState.DIRTY
44
 
45
- def process(self) -> None:
46
  if self.on_process:
47
- self.on_process(self)
 
 
 
 
 
48
  else:
49
- raise NotImplementedError(f"No process callback for {self.node_type.kind.value}")
 
1
  from __future__ import annotations
2
+
3
  from dataclasses import dataclass, field
4
  from typing import Iterable, Callable
5
+
6
  from .enums import NodeKind, DataPortState
7
+ from .ports import PortSchema, PortState
8
 
9
 
10
  @dataclass(slots=True)
 
44
  for p in self.all_outputs():
45
  p.state = DataPortState.DIRTY
46
 
47
+ async def process(self) -> None:
48
  if self.on_process:
49
+ # check if on_process is async
50
+ import inspect
51
+ if inspect.iscoroutinefunction(self.on_process):
52
+ await self.on_process(self)
53
+ else:
54
+ self.on_process(self)
55
  else:
56
+ pass
dataflow/types.py CHANGED
@@ -1,6 +1,8 @@
1
  from __future__ import annotations
 
2
  from dataclasses import dataclass
3
  from typing import Generic, TypeVar, Type, Callable, Any
 
4
  from .enums import DataTypeId
5
 
6
  T = TypeVar("T")
@@ -21,3 +23,4 @@ class DataType(Generic[T]):
21
  py_type: Type[T]
22
  encode: Callable[[T], Any] | None = None
23
  decode: Callable[[Any], T] | None = None
 
 
1
  from __future__ import annotations
2
+
3
  from dataclasses import dataclass
4
  from typing import Generic, TypeVar, Type, Callable, Any
5
+
6
  from .enums import DataTypeId
7
 
8
  T = TypeVar("T")
 
23
  py_type: Type[T]
24
  encode: Callable[[T], Any] | None = None
25
  decode: Callable[[Any], T] | None = None
26
+ color: str = "#888888"
dataflow/ui/vueflow_canvas.vue CHANGED
@@ -14,6 +14,7 @@
14
  v-model:edges="edges"
15
  :fit-view-on-init="fitOnInit"
16
  :connection-mode="connectionMode"
 
17
  :snap-to-grid="snapToGrid"
18
  :edges-updatable="allowUserEdit"
19
  :nodes-draggable="allowUserEdit"
@@ -32,19 +33,20 @@
32
  @edges-delete="onEdgesDelete"
33
  @nodes-delete="onNodesDelete"
34
  >
35
- <!-- generic base node with left right handles and field based UI -->
36
  <template #node-base="nodeProps">
37
- <div class="vf-node">
38
  <!-- input handles on the left -->
39
  <div class="vf-node-handles vf-node-handles-left" v-if="Handle">
40
  <component
41
- v-for="inp in (nodeProps.data.inputs || [])"
42
  :key="inp.name"
43
  :is="Handle"
44
  type="target"
45
  position="left"
46
  :id="nodeProps.id + ':' + inp.name"
47
  class="vf-handle vf-handle-left"
 
48
  />
49
  </div>
50
 
@@ -54,15 +56,58 @@
54
  <div class="vf-node-title">
55
  {{ nodeProps.data.title || nodeProps.id }}
56
  </div>
57
- <button
58
- v-if="nodeProps.data.executable"
59
- class="vf-exec-button"
60
- :disabled="nodeProps.data.processing"
61
- @click.stop="onExecuteClick(nodeProps)"
62
- >
63
- <span v-if="nodeProps.data.processing" class="vf-spinner"></span>
64
- <span v-else>Run</span>
65
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  </div>
67
 
68
  <div class="vf-node-content">
@@ -83,7 +128,63 @@
83
  />
84
  </div>
85
 
86
- <!-- image preview with optional url/base64 input -->
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  <div v-else-if="field.kind === 'image'">
88
  <div class="vf-image-preview-wrapper">
89
  <div
@@ -127,13 +228,14 @@
127
  <!-- output handles on the right -->
128
  <div class="vf-node-handles vf-node-handles-right" v-if="Handle">
129
  <component
130
- v-for="out in (nodeProps.data.outputs || [])"
131
  :key="out.name"
132
  :is="Handle"
133
  type="source"
134
  position="right"
135
  :id="nodeProps.id + ':' + out.name"
136
  class="vf-handle vf-handle-right"
 
137
  />
138
  </div>
139
  </div>
@@ -200,6 +302,7 @@ export default {
200
  snapToGrid: {type: Boolean, default: true},
201
  connectionMode: {type: String, default: "loose"},
202
  allowUserEdit: {type: Boolean, default: true},
 
203
 
204
  // CDN versions
205
  coreVersion: {type: String, default: "1.47.0"},
@@ -207,12 +310,14 @@ export default {
207
 
208
  // optional initial graph
209
  initialNodes: {
210
- type: Array, default: function () {
 
211
  return [];
212
  }
213
  },
214
  initialEdges: {
215
- type: Array, default: function () {
 
216
  return [];
217
  }
218
  },
@@ -246,7 +351,10 @@ export default {
246
  contextMenuVisible: false,
247
  contextMenuFlowPosition: null,
248
 
249
- windowKeyHandler: null
 
 
 
250
  };
251
  },
252
 
@@ -295,6 +403,7 @@ export default {
295
  this.pendingGraph = null;
296
  this.contextMenuVisible = false;
297
  this.contextMenuFlowPosition = null;
 
298
 
299
  if (this.windowKeyHandler) {
300
  window.removeEventListener("keydown", this.windowKeyHandler);
@@ -303,6 +412,21 @@ export default {
303
  },
304
 
305
  methods: {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
306
  // public API for Python
307
 
308
  setGraph(payload) {
@@ -377,6 +501,21 @@ export default {
377
  }
378
  },
379
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
380
  setNodeProcessing(payload) {
381
  if (!payload || !payload.id) return;
382
  var id = payload.id;
@@ -461,6 +600,48 @@ export default {
461
  });
462
  },
463
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
464
  normalizeImageSrc(src) {
465
  if (!src) return "";
466
  if (typeof src !== "string") {
@@ -483,6 +664,32 @@ export default {
483
  return "data:image/png;base64," + trimmed;
484
  },
485
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
486
  isValidConnection(conn) {
487
  if (!conn) return false;
488
  var nodes = this.nodes || [];
@@ -570,11 +777,13 @@ export default {
570
  openCreateNodeMenu(flowPos) {
571
  this.contextMenuFlowPosition = flowPos || null;
572
  this.contextMenuVisible = true;
 
573
  },
574
 
575
  hideContextMenu() {
576
  this.contextMenuVisible = false;
577
  this.contextMenuFlowPosition = null;
 
578
  },
579
 
580
  onCreateNodeClick(entry) {
@@ -919,7 +1128,7 @@ export default {
919
  font-size: 13px;
920
  }
921
 
922
- /* custom node layout with left right handles */
923
  .vf-node {
924
  display: flex;
925
  align-items: stretch;
@@ -930,13 +1139,28 @@ export default {
930
  font-size: 12px;
931
  box-shadow: 0 1px 2px rgba(15, 23, 42, 0.18);
932
  min-width: 180px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
933
  }
934
 
935
  .vf-node-handles {
936
  display: flex;
937
  flex-direction: column;
938
  justify-content: center;
939
- gap: 8px;
940
  }
941
 
942
  .vf-node-handles-left {
@@ -947,6 +1171,10 @@ export default {
947
  margin-left: 4px;
948
  }
949
 
 
 
 
 
950
  .vf-node-body {
951
  flex: 1 1 auto;
952
  padding: 6px 8px 8px 8px;
@@ -965,6 +1193,12 @@ export default {
965
  font-weight: 600;
966
  }
967
 
 
 
 
 
 
 
968
  .vf-node-content {
969
  display: flex;
970
  flex-direction: column;
@@ -986,6 +1220,22 @@ export default {
986
  box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.35);
987
  }
988
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
989
  .vf-exec-button {
990
  border: none;
991
  background: #3b82f6;
@@ -997,6 +1247,7 @@ export default {
997
  display: inline-flex;
998
  align-items: center;
999
  justify-content: center;
 
1000
  }
1001
 
1002
  .vf-exec-button[disabled] {
@@ -1004,6 +1255,10 @@ export default {
1004
  cursor: default;
1005
  }
1006
 
 
 
 
 
1007
  .vf-spinner {
1008
  width: 12px;
1009
  height: 12px;
@@ -1022,6 +1277,35 @@ export default {
1022
  }
1023
  }
1024
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1025
  .vf-image-preview-wrapper {
1026
  width: 160px;
1027
  height: 100px;
@@ -1053,9 +1337,76 @@ export default {
1053
  height: 100%;
1054
  }
1055
 
1056
- .vf-node-generic {
 
 
 
 
 
 
 
 
1057
  font-size: 11px;
1058
- color: #6b7280;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1059
  }
1060
 
1061
  /* dialog styling */
 
14
  v-model:edges="edges"
15
  :fit-view-on-init="fitOnInit"
16
  :connection-mode="connectionMode"
17
+ :connection-radius="connectionRadius"
18
  :snap-to-grid="snapToGrid"
19
  :edges-updatable="allowUserEdit"
20
  :nodes-draggable="allowUserEdit"
 
33
  @edges-delete="onEdgesDelete"
34
  @nodes-delete="onNodesDelete"
35
  >
36
+ <!-- generic base node with left/right handles and field based UI -->
37
  <template #node-base="nodeProps">
38
+ <div class="vf-node" :class="{ 'vf-node-selected': nodeProps.selected }">
39
  <!-- input handles on the left -->
40
  <div class="vf-node-handles vf-node-handles-left" v-if="Handle">
41
  <component
42
+ v-for="(inp, index) in (nodeProps.data.inputs || [])"
43
  :key="inp.name"
44
  :is="Handle"
45
  type="target"
46
  position="left"
47
  :id="nodeProps.id + ':' + inp.name"
48
  class="vf-handle vf-handle-left"
49
+ :style="getHandleStyle('left', index, nodeProps.data.inputs, inp)"
50
  />
51
  </div>
52
 
 
56
  <div class="vf-node-title">
57
  {{ nodeProps.data.title || nodeProps.id }}
58
  </div>
59
+ <div class="vf-node-header-actions">
60
+ <a
61
+ v-if="getImageResultValue(nodeProps)"
62
+ :href="normalizeImageSrc(getImageResultValue(nodeProps))"
63
+ download="generated.png"
64
+ class="vf-exec-button vf-download-header"
65
+ @click.stop
66
+ >
67
+ Download
68
+ </a>
69
+ <button
70
+ v-if="nodeProps.data.executable"
71
+ class="vf-exec-button"
72
+ :disabled="nodeProps.data.processing"
73
+ @click.stop="onExecuteClick(nodeProps)"
74
+ >
75
+ <span v-if="nodeProps.data.processing" class="vf-spinner"></span>
76
+ <span v-else>Run</span>
77
+ </button>
78
+
79
+ <!-- simple dropdown for node actions -->
80
+ <div class="vf-node-menu" @click.stop>
81
+ <button
82
+ type="button"
83
+ class="vf-node-menu-button"
84
+ @click.stop="toggleNodeMenu(nodeProps.id)"
85
+ >
86
+ <span class="vf-node-menu-dot"></span>
87
+ <span class="vf-node-menu-dot"></span>
88
+ <span class="vf-node-menu-dot"></span>
89
+ </button>
90
+ <div v-if="isNodeMenuOpen(nodeProps.id)" class="vf-node-menu-items">
91
+ <button
92
+ type="button"
93
+ class="vf-node-menu-item"
94
+ @click.stop="onNodeMenuAction(nodeProps.id, 'delete')"
95
+ >
96
+ Delete node
97
+ </button>
98
+ </div>
99
+ </div>
100
+ </div>
101
+ </div>
102
+
103
+ <!-- node level error -->
104
+ <div v-if="nodeProps.data.values && nodeProps.data.values.error" class="vf-node-error">
105
+ {{ nodeProps.data.values.error }}
106
+ </div>
107
+
108
+ <!-- processing progress bar -->
109
+ <div v-if="nodeProps.data.processing" class="vf-node-progress">
110
+ <div class="vf-node-progress-bar"></div>
111
  </div>
112
 
113
  <div class="vf-node-content">
 
128
  />
129
  </div>
130
 
131
+ <!-- textarea field -->
132
+ <div v-else-if="field.kind === 'textarea'">
133
+ <textarea
134
+ class="vf-node-textarea"
135
+ rows="5"
136
+ v-model="nodeProps.data.values[field.name]"
137
+ @blur="onFieldBlur(nodeProps, field)"
138
+ :placeholder="field.placeholder || ''"
139
+ ></textarea>
140
+ </div>
141
+
142
+ <!-- image upload field -->
143
+ <div v-else-if="field.kind === 'image_upload'">
144
+ <div class="vf-image-upload-controls">
145
+ <label class="vf-exec-button vf-upload-header-btn">
146
+ Upload
147
+ <input
148
+ type="file"
149
+ accept="image/*"
150
+ @change="onImageUpload($event, nodeProps, field)"
151
+ style="display:none"
152
+ />
153
+ </label>
154
+ <button
155
+ type="button"
156
+ class="vf-exec-button vf-upload-header-btn-secondary"
157
+ @click="onImageClipboard(nodeProps, field)"
158
+ >
159
+ Paste
160
+ </button>
161
+ </div>
162
+ <div class="vf-image-preview-wrapper"
163
+ v-if="nodeProps.data.values && nodeProps.data.values[field.name]">
164
+ <img :src="normalizeImageSrc(nodeProps.data.values[field.name])"/>
165
+ </div>
166
+ <div class="vf-image-placeholder" v-else>No image</div>
167
+ </div>
168
+
169
+ <!-- image result (download is in header) -->
170
+ <div v-else-if="field.kind === 'image_result'">
171
+ <div class="vf-image-preview-wrapper">
172
+ <div
173
+ v-if="nodeProps.data.values && nodeProps.data.values[field.name]"
174
+ class="vf-image-preview"
175
+ >
176
+ <img
177
+ :src="normalizeImageSrc(nodeProps.data.values[field.name])"
178
+ alt="node image"
179
+ />
180
+ </div>
181
+ <div v-else class="vf-image-placeholder">
182
+ {{ field.placeholder || 'No image' }}
183
+ </div>
184
+ </div>
185
+ </div>
186
+
187
+ <!-- legacy image preview with optional url/base64 input -->
188
  <div v-else-if="field.kind === 'image'">
189
  <div class="vf-image-preview-wrapper">
190
  <div
 
228
  <!-- output handles on the right -->
229
  <div class="vf-node-handles vf-node-handles-right" v-if="Handle">
230
  <component
231
+ v-for="(out, index) in (nodeProps.data.outputs || [])"
232
  :key="out.name"
233
  :is="Handle"
234
  type="source"
235
  position="right"
236
  :id="nodeProps.id + ':' + out.name"
237
  class="vf-handle vf-handle-right"
238
+ :style="getHandleStyle('right', index, nodeProps.data.outputs, out)"
239
  />
240
  </div>
241
  </div>
 
302
  snapToGrid: {type: Boolean, default: true},
303
  connectionMode: {type: String, default: "loose"},
304
  allowUserEdit: {type: Boolean, default: true},
305
+ connectionRadius: {type: Number, default: 30},
306
 
307
  // CDN versions
308
  coreVersion: {type: String, default: "1.47.0"},
 
310
 
311
  // optional initial graph
312
  initialNodes: {
313
+ type: Array,
314
+ default: function () {
315
  return [];
316
  }
317
  },
318
  initialEdges: {
319
+ type: Array,
320
+ default: function () {
321
  return [];
322
  }
323
  },
 
351
  contextMenuVisible: false,
352
  contextMenuFlowPosition: null,
353
 
354
+ windowKeyHandler: null,
355
+
356
+ // which node menu is open
357
+ nodeMenuFor: null
358
  };
359
  },
360
 
 
403
  this.pendingGraph = null;
404
  this.contextMenuVisible = false;
405
  this.contextMenuFlowPosition = null;
406
+ this.nodeMenuFor = null;
407
 
408
  if (this.windowKeyHandler) {
409
  window.removeEventListener("keydown", this.windowKeyHandler);
 
412
  },
413
 
414
  methods: {
415
+ // node menu helpers
416
+
417
+ toggleNodeMenu(id) {
418
+ this.nodeMenuFor = this.nodeMenuFor === id ? null : id;
419
+ },
420
+
421
+ isNodeMenuOpen(id) {
422
+ return this.nodeMenuFor === id;
423
+ },
424
+
425
+ onNodeMenuAction(id, action) {
426
+ this.nodeMenuFor = null;
427
+ this.emitEvent("node_menu_action", {id: id, action: action});
428
+ },
429
+
430
  // public API for Python
431
 
432
  setGraph(payload) {
 
501
  }
502
  },
503
 
504
+ clearGraph() {
505
+ var ok = window.confirm("Clear the entire graph? This cannot be undone.");
506
+ if (!ok) return;
507
+
508
+ this.nodes = [];
509
+ this.edges = [];
510
+
511
+ if (this.vf_api) {
512
+ if (this.vf_api.setNodes) this.vf_api.setNodes([]);
513
+ if (this.vf_api.setEdges) this.vf_api.setEdges([]);
514
+ }
515
+
516
+ this.emitEvent("graph_cleared", {cleared: true});
517
+ },
518
+
519
  setNodeProcessing(payload) {
520
  if (!payload || !payload.id) return;
521
  var id = payload.id;
 
600
  });
601
  },
602
 
603
+ onImageUpload(event, nodeProps, field) {
604
+ const file = event.target.files[0];
605
+ if (!file) return;
606
+ const reader = new FileReader();
607
+ reader.onload = (e) => {
608
+ const res = e.target.result;
609
+ if (!nodeProps.data.values) nodeProps.data.values = {};
610
+ nodeProps.data.values[field.name] = res;
611
+ this.onFieldBlur(nodeProps, field);
612
+ };
613
+ reader.readAsDataURL(file);
614
+ },
615
+
616
+ async onImageClipboard(nodeProps, field) {
617
+ try {
618
+ const items = await navigator.clipboard.read();
619
+ for (const item of items) {
620
+ if (item.types.some(function (t) {
621
+ return t.startsWith("image/");
622
+ })) {
623
+ const type = item.types.find(function (t) {
624
+ return t.startsWith("image/");
625
+ });
626
+ const blob = await item.getType(type);
627
+ const reader = new FileReader();
628
+ reader.onload = (e) => {
629
+ const res = e.target.result;
630
+ if (!nodeProps.data.values) nodeProps.data.values = {};
631
+ nodeProps.data.values[field.name] = res;
632
+ this.onFieldBlur(nodeProps, field);
633
+ };
634
+ reader.readAsDataURL(blob);
635
+ return;
636
+ }
637
+ }
638
+ alert("No image found on clipboard");
639
+ } catch (err) {
640
+ console.error(err);
641
+ alert("Failed to read clipboard");
642
+ }
643
+ },
644
+
645
  normalizeImageSrc(src) {
646
  if (!src) return "";
647
  if (typeof src !== "string") {
 
664
  return "data:image/png;base64," + trimmed;
665
  },
666
 
667
+ getImageResultValue(nodeProps) {
668
+ if (!nodeProps || !nodeProps.data) return null;
669
+ var fields = Array.isArray(nodeProps.data.fields) ? nodeProps.data.fields : [];
670
+ var values = nodeProps.data.values || {};
671
+ for (var i = 0; i < fields.length; i++) {
672
+ var f = fields[i];
673
+ if (f && f.kind === "image_result" && values[f.name]) {
674
+ return values[f.name];
675
+ }
676
+ }
677
+ return null;
678
+ },
679
+
680
+ getHandleStyle(side, index, list, port) {
681
+ var len = Array.isArray(list) ? list.length : 1;
682
+ var step = 100 / (len + 1);
683
+ var top = step * (index + 1);
684
+ var style = {
685
+ top: top + "%"
686
+ };
687
+ if (port && port.color) {
688
+ style.backgroundColor = port.color;
689
+ }
690
+ return style;
691
+ },
692
+
693
  isValidConnection(conn) {
694
  if (!conn) return false;
695
  var nodes = this.nodes || [];
 
777
  openCreateNodeMenu(flowPos) {
778
  this.contextMenuFlowPosition = flowPos || null;
779
  this.contextMenuVisible = true;
780
+ this.nodeMenuFor = null;
781
  },
782
 
783
  hideContextMenu() {
784
  this.contextMenuVisible = false;
785
  this.contextMenuFlowPosition = null;
786
+ this.nodeMenuFor = null;
787
  },
788
 
789
  onCreateNodeClick(entry) {
 
1128
  font-size: 13px;
1129
  }
1130
 
1131
+ /* custom node layout with left/right handles */
1132
  .vf-node {
1133
  display: flex;
1134
  align-items: stretch;
 
1139
  font-size: 12px;
1140
  box-shadow: 0 1px 2px rgba(15, 23, 42, 0.18);
1141
  min-width: 180px;
1142
+ position: relative;
1143
+ }
1144
+
1145
+ .vf-node-selected {
1146
+ border-color: #3b82f6;
1147
+ box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.7), 0 4px 10px rgba(37, 99, 235, 0.25);
1148
+ }
1149
+
1150
+ .vf-node-error {
1151
+ color: #b91c1c;
1152
+ background-color: #fecaca;
1153
+ padding: 4px 8px;
1154
+ border-radius: 4px;
1155
+ margin-bottom: 4px;
1156
+ font-size: 11px;
1157
  }
1158
 
1159
  .vf-node-handles {
1160
  display: flex;
1161
  flex-direction: column;
1162
  justify-content: center;
1163
+ pointer-events: none;
1164
  }
1165
 
1166
  .vf-node-handles-left {
 
1171
  margin-left: 4px;
1172
  }
1173
 
1174
+ .vf-handle {
1175
+ pointer-events: auto;
1176
+ }
1177
+
1178
  .vf-node-body {
1179
  flex: 1 1 auto;
1180
  padding: 6px 8px 8px 8px;
 
1193
  font-weight: 600;
1194
  }
1195
 
1196
+ .vf-node-header-actions {
1197
+ display: inline-flex;
1198
+ align-items: center;
1199
+ gap: 4px;
1200
+ }
1201
+
1202
  .vf-node-content {
1203
  display: flex;
1204
  flex-direction: column;
 
1220
  box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.35);
1221
  }
1222
 
1223
+ .vf-node-textarea {
1224
+ width: 100%;
1225
+ box-sizing: border-box;
1226
+ border-radius: 4px;
1227
+ border: 1px solid rgba(148, 163, 184, 0.9);
1228
+ padding: 3px 5px;
1229
+ font-size: 12px;
1230
+ resize: none;
1231
+ }
1232
+
1233
+ .vf-node-textarea:focus {
1234
+ outline: none;
1235
+ border-color: #3b82f6;
1236
+ box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.35);
1237
+ }
1238
+
1239
  .vf-exec-button {
1240
  border: none;
1241
  background: #3b82f6;
 
1247
  display: inline-flex;
1248
  align-items: center;
1249
  justify-content: center;
1250
+ white-space: nowrap;
1251
  }
1252
 
1253
  .vf-exec-button[disabled] {
 
1255
  cursor: default;
1256
  }
1257
 
1258
+ .vf-download-header {
1259
+ background: #2563eb;
1260
+ }
1261
+
1262
  .vf-spinner {
1263
  width: 12px;
1264
  height: 12px;
 
1277
  }
1278
  }
1279
 
1280
+ /* processing progress bar */
1281
+ .vf-node-progress {
1282
+ position: relative;
1283
+ height: 4px;
1284
+ border-radius: 999px;
1285
+ overflow: hidden;
1286
+ background: #e5e7eb;
1287
+ margin-bottom: 4px;
1288
+ }
1289
+
1290
+ .vf-node-progress-bar {
1291
+ position: absolute;
1292
+ left: -40%;
1293
+ top: 0;
1294
+ bottom: 0;
1295
+ width: 40%;
1296
+ background: #3b82f6;
1297
+ animation: vf-progress 1s linear infinite;
1298
+ }
1299
+
1300
+ @keyframes vf-progress {
1301
+ from {
1302
+ transform: translateX(0);
1303
+ }
1304
+ to {
1305
+ transform: translateX(260%);
1306
+ }
1307
+ }
1308
+
1309
  .vf-image-preview-wrapper {
1310
  width: 160px;
1311
  height: 100px;
 
1337
  height: 100%;
1338
  }
1339
 
1340
+ /* upload controls - now small blue buttons like header */
1341
+ .vf-image-upload-controls {
1342
+ display: flex;
1343
+ gap: 4px;
1344
+ margin-bottom: 4px;
1345
+ }
1346
+
1347
+ .vf-upload-header-btn {
1348
+ padding: 2px 8px;
1349
  font-size: 11px;
1350
+ }
1351
+
1352
+ .vf-upload-header-btn-secondary {
1353
+ padding: 2px 8px;
1354
+ font-size: 11px;
1355
+ background: #e5f0ff;
1356
+ color: #1d4ed8;
1357
+ border-radius: 4px;
1358
+ border: 1px solid #bfdbfe;
1359
+ }
1360
+
1361
+ /* simple node menu */
1362
+
1363
+ .vf-node-menu {
1364
+ position: relative;
1365
+ }
1366
+
1367
+ .vf-node-menu-button {
1368
+ border: none;
1369
+ background: transparent;
1370
+ padding: 2px;
1371
+ display: inline-flex;
1372
+ flex-direction: column;
1373
+ justify-content: center;
1374
+ align-items: center;
1375
+ gap: 1px;
1376
+ cursor: pointer;
1377
+ }
1378
+
1379
+ .vf-node-menu-dot {
1380
+ width: 2px;
1381
+ height: 2px;
1382
+ border-radius: 999px;
1383
+ background: #6b7280;
1384
+ }
1385
+
1386
+ .vf-node-menu-items {
1387
+ position: absolute;
1388
+ right: 0;
1389
+ margin-top: 4px;
1390
+ min-width: 120px;
1391
+ border-radius: 4px;
1392
+ background: #ffffff;
1393
+ box-shadow: 0 4px 10px rgba(15, 23, 42, 0.15);
1394
+ padding: 4px 0;
1395
+ z-index: 30;
1396
+ }
1397
+
1398
+ .vf-node-menu-item {
1399
+ width: 100%;
1400
+ padding: 4px 8px;
1401
+ background: transparent;
1402
+ border: none;
1403
+ text-align: left;
1404
+ font-size: 11px;
1405
+ cursor: pointer;
1406
+ }
1407
+
1408
+ .vf-node-menu-item:hover {
1409
+ background: #eff6ff;
1410
  }
1411
 
1412
  /* dialog styling */
main.py CHANGED
@@ -1,153 +1,141 @@
1
  from __future__ import annotations
2
 
3
- import itertools
4
  import os
5
 
 
6
  from fastapi import FastAPI
7
- from nicegui import ui, Client
8
 
9
- from dataflow.enums import NodeKind
10
- from dataflow.graph import DataGraph
11
- from dataflow.registry import Registry
12
  from dataflow.ui.vueflow_canvas import VueFlowCanvas
13
- from nodes.controller import GraphController
14
- from nodes.runtime import GraphRuntime
15
- from nodes.text_data import TextDataNodeType, TextDataNode
16
- from nodes.text_to_image import TextToImageNodeType, TextToImageNode
17
- from nodes.vue_nodes import VueNodeRenderer
18
 
19
  fastapi_app = FastAPI()
20
- ui.run_with(fastapi_app)
21
 
22
- registry = Registry()
23
- registry.register_node_type(TextDataNodeType, lambda node_id: TextDataNode(node_id, TextDataNodeType))
24
- registry.register_node_type(TextToImageNodeType, lambda node_id: TextToImageNode(node_id, TextToImageNodeType))
25
-
26
- graph = DataGraph()
27
- renderer = VueNodeRenderer()
28
- controller = GraphController(graph=graph)
29
-
30
- # which node types the user can create from the UI
31
- creatable_node_types = [
32
- {
33
- "kind": NodeKind.TEXT_DATA.value,
34
- "title": TextDataNodeType.display_name,
35
- },
36
- {
37
- "kind": NodeKind.TEXT_TO_IMAGE.value,
38
- "title": TextToImageNodeType.display_name,
39
- },
40
- ]
41
-
42
- # simple id generator for nodes created from the UI
43
- _node_id_counter = itertools.count(1)
44
-
45
-
46
- def next_ui_node_id() -> str:
47
- return f"u{next(_node_id_counter)}"
48
-
49
-
50
- # create by NodeType object
51
- text_node = registry.create(TextDataNodeType, "t1")
52
- text_node2 = registry.create(TextDataNodeType, "t2")
53
- text2img_node = registry.create(TextToImageNodeType, "n2")
54
-
55
- text_node.x, text_node.y = 100, 100
56
- text2img_node.x, text2img_node.y = 420, 140
57
-
58
- graph.add_node(text_node)
59
- graph.add_node(text_node2)
60
- graph.add_node(text2img_node)
61
-
62
- initial_nodes = [
63
- renderer.to_vue_node(text_node),
64
- renderer.to_vue_node(text_node2),
65
- renderer.to_vue_node(text2img_node),
66
- ]
67
- initial_edges: list[dict] = []
68
 
69
 
70
  @ui.page("/")
71
- def main(client: Client):
 
72
  ui.query(".nicegui-content").classes("p-0")
73
 
74
- with ui.row().classes("w-full h-screen no-wrap"):
75
- canvas = VueFlowCanvas(
76
- creatable_node_types=creatable_node_types,
77
- ).classes("w-full h-full flex-1")
78
- runtime = GraphRuntime(graph=graph, canvas=canvas)
79
-
80
- async def handle_event(e) -> None:
81
- event = e.args
82
- print(f"graph event: {event}")
83
- event_type = event.get("type")
84
- payload = event.get("payload")
85
-
86
- if event_type == "execute_node":
87
- payload_dict = payload or {}
88
- node_id = payload_dict.get("id")
89
- if node_id:
90
- await runtime.execute_node(node_id)
91
-
92
- elif event_type == "create_node":
93
- payload_dict = payload or {}
94
- kind_value = payload_dict.get("kind")
95
- position = payload_dict.get("position") or {}
96
-
97
- if not kind_value:
98
- return
99
-
100
- # map string back to NodeKind
101
- try:
102
- kind = NodeKind(kind_value)
103
- except Exception:
104
- # fallback lookup by display name or kind string
105
- kind = None
106
- if isinstance(kind_value, str):
107
- for k, node_type in registry.node_types.items():
108
- if k.value == kind_value or node_type.display_name == kind_value:
109
- kind = k
110
- break
111
- if kind is None:
112
- return
113
-
114
- node_id = next_ui_node_id()
115
- try:
116
- node_obj = registry.create(kind, node_id)
117
- except KeyError:
118
- return
119
-
120
- x = position.get("x")
121
- y = position.get("y")
122
- if x is not None:
123
- try:
124
- node_obj.x = float(x)
125
- except (TypeError, ValueError):
126
- pass
127
- if y is not None:
128
- try:
129
- node_obj.y = float(y)
130
- except (TypeError, ValueError):
131
- pass
132
-
133
- graph.add_node(node_obj)
134
- vue_node = renderer.to_vue_node(node_obj)
135
  canvas.add_node(vue_node)
136
 
137
- else:
138
- controller.handle_event(event)
139
-
140
- canvas.on("vf_event", handle_event)
141
- canvas.set_graph(initial_nodes, initial_edges)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
 
143
 
144
  if __name__ in {"__main__", "__mp_main__"}:
145
- import uvicorn
146
-
147
  uvicorn.run(
148
  "main:fastapi_app",
149
  host="0.0.0.0",
150
  port=int(os.getenv("PORT", "7860")),
151
  proxy_headers=True,
152
- forwarded_allow_ips="*"
153
  )
 
1
  from __future__ import annotations
2
 
 
3
  import os
4
 
5
+ import uvicorn
6
  from fastapi import FastAPI
7
+ from nicegui import ui, Client, app
8
 
 
 
 
9
  from dataflow.ui.vueflow_canvas import VueFlowCanvas
10
+ from nodes.session import GraphSession
 
 
 
 
11
 
12
  fastapi_app = FastAPI()
13
+ ui.run_with(fastapi_app, storage_secret="velai-storage-secret")
14
 
15
+ APP_PASSWORD = os.getenv("VELAI_PASSWORD") or ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
 
18
  @ui.page("/")
19
+ async def main(client: Client) -> None:
20
+ ui.page_title("velai")
21
  ui.query(".nicegui-content").classes("p-0")
22
 
23
+ await client.connected()
24
+
25
+ # password gate: require once per user while APP_PASSWORD stays the same
26
+ password_required = False
27
+ if APP_PASSWORD:
28
+ auth = app.storage.user.get("auth") or {}
29
+ if not (auth.get("ok") and auth.get("password_version") == APP_PASSWORD):
30
+ password_required = True
31
+
32
+ # one GraphSession per browser tab
33
+ if "graph_session" not in app.storage.tab:
34
+ app.storage.tab["graph_session"] = GraphSession.create_default()
35
+ session: GraphSession = app.storage.tab["graph_session"]
36
+
37
+ initial_nodes = session.initial_vue_nodes()
38
+ initial_edges = session.initial_vue_edges()
39
+
40
+ with ui.column().classes("w-full h-screen no-wrap"):
41
+ # header
42
+ with ui.row().classes(
43
+ "w-full items-center justify-between px-4 py-2 bg-grey-2"
44
+ ):
45
+ ui.label("velai").classes("text-lg font-bold")
46
+
47
+ with ui.row().classes("items-center gap-2"):
48
+ with ui.dropdown_button("Add Node", auto_close=True):
49
+ kinds = session.creatable_node_types
50
+ for k in kinds:
51
+ ui.menu_item(k["title"], on_click=lambda _, kind=k["kind"]: add_node_action(kind))
52
+
53
+ with ui.dropdown_button("Graph", auto_close=True):
54
+ ui.menu_item("Export JSON", on_click=lambda: export_action())
55
+ ui.menu_item("Import JSON", on_click=lambda: import_action())
56
+
57
+ clear_button = ui.button("Clear graph").props("flat color='negative'")
58
+
59
+ # canvas area fills the remaining height
60
+ with ui.row().classes("w-full flex-1 no-wrap"):
61
+ canvas = VueFlowCanvas(
62
+ creatable_node_types=session.creatable_node_types,
63
+ ).classes("w-full h-full flex-1")
64
+
65
+ session.attach_canvas(canvas)
66
+
67
+ async def handle_event(e) -> None:
68
+ await session.handle_ui_event(e.args, canvas)
69
+
70
+ canvas.on("vf_event", handle_event)
71
+ canvas.set_graph(initial_nodes, initial_edges)
72
+
73
+ # wire header actions now that canvas exists
74
+ def add_node_action(kind_value: str) -> None:
75
+ import random
76
+ vue_node = session.create_node(kind_value, position={"x": 100 + random.randint(0, 50),
77
+ "y": 100 + random.randint(0, 50)})
78
+ if vue_node is not None:
 
 
 
 
 
79
  canvas.add_node(vue_node)
80
 
81
+ def clear_graph_action() -> None:
82
+ session.clear_graph()
83
+ canvas.set_graph([], [])
84
+
85
+ async def export_action() -> None:
86
+ json_str = session.to_json()
87
+ ui.download(json_str.encode("utf-8"), "graph.json")
88
+
89
+ async def import_action() -> None:
90
+ with ui.dialog() as dialog, ui.card():
91
+ ui.label("Upload Graph JSON")
92
+ ui.upload(on_upload=lambda e: [load_file(e), dialog.close()], auto_upload=True, max_files=1)
93
+ dialog.open()
94
+
95
+ def load_file(e) -> None:
96
+ content = e.content.read().decode("utf-8")
97
+ session.load_from_json(content)
98
+ nodes = session.initial_vue_nodes()
99
+ edges = session.initial_vue_edges()
100
+ canvas.set_graph(nodes, edges)
101
+
102
+ clear_button.on_click(clear_graph_action)
103
+
104
+ # password dialog on top of everything if required
105
+ if password_required:
106
+ with ui.dialog() as dialog, ui.card():
107
+ await dialog.props("persistent")
108
+ ui.label("Enter password").classes("text-md font-bold mb-2")
109
+ pwd_input = ui.input(label="Password").props('type="password"')
110
+ error_label = ui.label("").style(
111
+ "color: red; font-size: 0.8rem; min-height: 1rem"
112
+ )
113
+
114
+ def submit() -> None:
115
+ value = pwd_input.value or ""
116
+ if value == APP_PASSWORD:
117
+ app.storage.user["auth"] = {
118
+ "ok": True,
119
+ "password_version": APP_PASSWORD,
120
+ }
121
+ error_label.text = ""
122
+ dialog.close()
123
+ else:
124
+ error_label.text = "Wrong password"
125
+
126
+ pwd_input.on("keydown.enter", lambda _: submit())
127
+
128
+ with ui.row().classes("mt-2 items-center justify-end gap-2"):
129
+ ui.button("Enter", on_click=submit).props("color='primary'")
130
+
131
+ dialog.open()
132
 
133
 
134
  if __name__ in {"__main__", "__mp_main__"}:
 
 
135
  uvicorn.run(
136
  "main:fastapi_app",
137
  host="0.0.0.0",
138
  port=int(os.getenv("PORT", "7860")),
139
  proxy_headers=True,
140
+ forwarded_allow_ips="*",
141
  )
nodes/data_types.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from PIL import Image
4
+
5
+ from dataflow.enums import DataTypeId
6
+ from dataflow.types import DataType
7
+ from nodes import utils
8
+
9
+ TextType = DataType[str](
10
+ id=DataTypeId.TEXT,
11
+ name="Text",
12
+ py_type=str,
13
+ color="#eab308",
14
+ )
15
+
16
+ ImageType = DataType(
17
+ id=DataTypeId.IMAGE,
18
+ name="Image",
19
+ py_type=Image.Image,
20
+ encode=utils.encode_image_png,
21
+ decode=utils.decode_image_png,
22
+ color="#a855f7",
23
+ )
nodes/image_data.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from dataflow.enums import NodeKind, PortDirection
6
+ from dataflow.nodes_base import NodeType, NodeInstance
7
+ from dataflow.ports import PortSchema
8
+ from nodes.data_types import ImageType
9
+ from nodes.vue_nodes import VueNodeRenderable, VueNodeData
10
+
11
+ ImageDataNodeType = NodeType(
12
+ kind=NodeKind.IMAGE_DATA,
13
+ display_name="Image",
14
+ inputs=[],
15
+ outputs=[PortSchema(name="image", dtype=ImageType, direction=PortDirection.OUTPUT)],
16
+ )
17
+
18
+
19
+ @dataclass(slots=True)
20
+ class ImageDataNode(NodeInstance):
21
+ """Node that exposes a constant image value via its output port.
22
+
23
+ The text is stored on the "text" output port and edited through the UI.
24
+ """
25
+
26
+ async def process(self) -> None:
27
+ """Data nodes do not compute anything, they just expose their current value.
28
+
29
+ The UI writes into the "text" output port. Here we simply ensure the port
30
+ exists and treat the current value as the result.
31
+ """
32
+ port = self.outputs.get("image") if self.outputs is not None else None
33
+ if port is None:
34
+ return
35
+ # nothing to compute, but this method must exist so graph.execute
36
+ # can safely walk upstream without raising
37
+ return
38
+
39
+
40
+ class ImageDataNodeRenderable(VueNodeRenderable[ImageDataNode]):
41
+ def to_vue_node_data(self, node: ImageDataNode) -> VueNodeData:
42
+ data = VueNodeData()
43
+
44
+ data.fields.append(
45
+ {
46
+ "name": "image",
47
+ "kind": "image_upload",
48
+ "label": "Image",
49
+ "placeholder": "Upload or paste image...",
50
+ }
51
+ )
52
+
53
+ port = node.outputs.get("image") if node.outputs is not None else None
54
+ data.values["image"] = "" if port is None or port.value is None else str(port.value)
55
+
56
+ data.executable = False
57
+ return data
nodes/runtime.py CHANGED
@@ -5,7 +5,6 @@ from dataclasses import dataclass
5
  from dataflow.graph import DataGraph
6
  from dataflow.nodes_base import NodeInstance
7
  from dataflow.ui.vueflow_canvas import VueFlowCanvas
8
-
9
  from .text_to_image import TextToImageNode
10
 
11
 
@@ -34,8 +33,13 @@ class GraphRuntime:
34
  self.canvas.set_node_processing(node_id, True)
35
  try:
36
  # run the dataflow execution chain (this will also execute upstream nodes)
37
- self.graph.execute(node_obj)
 
38
  await self._sync_node_to_ui(node_obj)
 
 
 
 
39
  finally:
40
  # hide spinner
41
  self.canvas.set_node_processing(node_id, False)
@@ -44,4 +48,7 @@ class GraphRuntime:
44
  """Push relevant node state back to the Vue nodes."""
45
  if isinstance(node, TextToImageNode):
46
  image_src = "" if node.image_src is None else str(node.image_src)
47
- self.canvas.update_node_values(node.node_id, {"image": image_src})
 
 
 
 
5
  from dataflow.graph import DataGraph
6
  from dataflow.nodes_base import NodeInstance
7
  from dataflow.ui.vueflow_canvas import VueFlowCanvas
 
8
  from .text_to_image import TextToImageNode
9
 
10
 
 
33
  self.canvas.set_node_processing(node_id, True)
34
  try:
35
  # run the dataflow execution chain (this will also execute upstream nodes)
36
+ print(f"Runtime: Executing {node_id}...")
37
+ await self.graph.execute(node_obj)
38
  await self._sync_node_to_ui(node_obj)
39
+ except Exception as e:
40
+ print(f"Runtime execution failed: {e}")
41
+ import traceback
42
+ traceback.print_exc()
43
  finally:
44
  # hide spinner
45
  self.canvas.set_node_processing(node_id, False)
 
48
  """Push relevant node state back to the Vue nodes."""
49
  if isinstance(node, TextToImageNode):
50
  image_src = "" if node.image_src is None else str(node.image_src)
51
+ values = {"image": image_src}
52
+ if node.error:
53
+ values["error"] = node.error
54
+ self.canvas.update_node_values(node.node_id, values)
nodes/session.py ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import itertools
4
+ import json
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ from dataflow.codecs import connection_to_vueflow_edge
9
+ from dataflow.connection import Connection
10
+ from dataflow.enums import NodeKind
11
+ from dataflow.graph import DataGraph
12
+ from dataflow.registry import Registry
13
+ from dataflow.ui.vueflow_canvas import VueFlowCanvas
14
+ from .controller import GraphController
15
+ from .image_data import ImageDataNodeType, ImageDataNode, ImageDataNodeRenderable
16
+ from .runtime import GraphRuntime
17
+ from .text_data import TextDataNodeType, TextDataNode, TextDataNodeRenderable
18
+ from .text_to_image import TextToImageNodeType, TextToImageNode, TextToImageNodeRenderable
19
+ from .vue_nodes import VueNodeRenderer
20
+
21
+
22
+ @dataclass(slots=True)
23
+ class GraphSession:
24
+ """Per browser tab graph session."""
25
+
26
+ registry: Registry
27
+ graph: DataGraph
28
+ renderer: VueNodeRenderer
29
+ controller: GraphController
30
+ runtime: GraphRuntime | None = None
31
+ _node_id_counter: itertools.count = field(
32
+ default_factory=lambda: itertools.count(1)
33
+ )
34
+
35
+ @classmethod
36
+ def create_default(cls) -> "GraphSession":
37
+ """Create a new session with default node types and starter graph."""
38
+ # Register new node types here
39
+ registry = Registry()
40
+ registry.register_node_type(
41
+ TextDataNodeType, lambda node_id: TextDataNode(node_id, TextDataNodeType)
42
+ )
43
+ registry.register_node_type(
44
+ ImageDataNodeType, lambda node_id: ImageDataNode(node_id, ImageDataNodeType)
45
+ )
46
+ registry.register_node_type(
47
+ TextToImageNodeType,
48
+ lambda node_id: TextToImageNode(node_id, TextToImageNodeType),
49
+ )
50
+
51
+ graph = DataGraph()
52
+ controller = GraphController(graph=graph)
53
+
54
+ renderer = VueNodeRenderer()
55
+
56
+ # Register new node renderables here!
57
+ renderer.register(TextDataNode, TextDataNodeRenderable())
58
+ renderer.register(ImageDataNode, ImageDataNodeRenderable())
59
+ renderer.register(TextToImageNode, TextToImageNodeRenderable())
60
+
61
+ session = cls(
62
+ registry=registry,
63
+ graph=graph,
64
+ renderer=renderer,
65
+ controller=controller,
66
+ )
67
+ session._build_initial_graph()
68
+ return session
69
+
70
+ def _build_initial_graph(self) -> None:
71
+ """Add starter nodes to a fresh graph."""
72
+ text_node = self.registry.create(TextDataNodeType, "t1")
73
+ text2img_node = self.registry.create(TextToImageNodeType, "n2")
74
+
75
+ text_node.x, text_node.y = 100, 100
76
+ text2img_node.x, text2img_node.y = 420, 100
77
+
78
+ self.graph.add_node(text_node)
79
+ self.graph.add_node(text2img_node)
80
+
81
+ edge = Connection(text_node, text_node.outputs["text"],
82
+ text2img_node, text2img_node.inputs["text"])
83
+
84
+ self.graph.add_connection(edge)
85
+
86
+ @property
87
+ def creatable_node_types(self) -> list[dict[str, str]]:
88
+ """Metadata for the node types the UI may create."""
89
+ return [
90
+ {
91
+ "kind": TextDataNodeType.kind.value,
92
+ "title": TextDataNodeType.display_name,
93
+ },
94
+ {
95
+ "kind": ImageDataNodeType.kind.value,
96
+ "title": ImageDataNodeType.display_name,
97
+ },
98
+ {
99
+ "kind": TextToImageNodeType.kind.value,
100
+ "title": TextToImageNodeType.display_name,
101
+ },
102
+ ]
103
+
104
+ def attach_canvas(self, canvas: VueFlowCanvas) -> None:
105
+ """Attach a VueFlowCanvas to this session."""
106
+ self.runtime = GraphRuntime(graph=self.graph, canvas=canvas)
107
+
108
+ def initial_vue_nodes(self) -> list[dict[str, Any]]:
109
+ """Serialize current graph nodes to Vue Flow node dicts with UI metadata."""
110
+ return [self.renderer.to_vue_node(n) for n in self.graph.nodes.values()]
111
+
112
+ def initial_vue_edges(self) -> list[dict[str, Any]]:
113
+ """Serialize current graph connections to Vue Flow edge dicts."""
114
+ return [connection_to_vueflow_edge(c) for c in self.graph.connections]
115
+
116
+ def to_json(self) -> str:
117
+ """Export graph to JSON."""
118
+ nodes_data = []
119
+ for node in self.graph.nodes.values():
120
+ # Extract UI values using the renderer logic
121
+ vue_data = self.renderer.to_vue_node(node)["data"].get("values", {})
122
+
123
+ nodes_data.append({
124
+ "id": node.node_id,
125
+ "kind": node.node_type.kind.value,
126
+ "x": node.x,
127
+ "y": node.y,
128
+ "values": vue_data
129
+ })
130
+
131
+ edges_data = []
132
+ for c in self.graph.connections:
133
+ edges_data.append({
134
+ "source": c.start_node.node_id,
135
+ "sourceHandle": c.start_port.name,
136
+ "target": c.end_node.node_id,
137
+ "targetHandle": c.end_port.name
138
+ })
139
+
140
+ return json.dumps({"nodes": nodes_data, "edges": edges_data}, indent=2)
141
+
142
+ def load_from_json(self, json_str: str) -> None:
143
+ """Import graph from JSON."""
144
+ try:
145
+ data = json.loads(json_str)
146
+ except Exception:
147
+ return
148
+
149
+ self.clear_graph()
150
+
151
+ # Recreate nodes
152
+ for n_data in data.get("nodes", []):
153
+ kind_val = n_data.get("kind")
154
+ node_id = n_data.get("id")
155
+
156
+ kind = self._node_kind_from_value(kind_val)
157
+ if not kind or not node_id:
158
+ continue
159
+
160
+ node = self.registry.create(kind, node_id)
161
+ node.x = n_data.get("x", 0)
162
+ node.y = n_data.get("y", 0)
163
+
164
+ # Apply values via controller to ensure side-effects (like updating port values) run
165
+ values = n_data.get("values", {})
166
+ self.graph.add_node(node) # Add first so controller finds it
167
+
168
+ for field, value in values.items():
169
+ self.controller._on_node_field_changed({"id": node_id, "field": field, "value": value})
170
+
171
+ # Update id counter to avoid collisions with new nodes
172
+ if node_id.startswith("u"):
173
+ try:
174
+ num = int(node_id[1:])
175
+ if num >= next(self._node_id_counter) - 1:
176
+ self._node_id_counter = itertools.count(num + 1)
177
+ except:
178
+ pass
179
+
180
+ # Recreate edges
181
+ from dataflow.connection import Connection
182
+ for e_data in data.get("edges", []):
183
+ src_node = self.graph.nodes.get(e_data.get("source"))
184
+ tgt_node = self.graph.nodes.get(e_data.get("target"))
185
+ if src_node and tgt_node:
186
+ src_port = src_node.outputs.get(e_data.get("sourceHandle"))
187
+ tgt_port = tgt_node.inputs.get(e_data.get("targetHandle"))
188
+
189
+ if src_port and tgt_port:
190
+ try:
191
+ conn = Connection(src_node, src_port, tgt_node, tgt_port)
192
+ self.graph.add_connection(conn)
193
+ except ValueError:
194
+ pass
195
+
196
+ def next_ui_node_id(self) -> str:
197
+ return f"u{next(self._node_id_counter)}"
198
+
199
+ def clear_graph(self) -> None:
200
+ """Remove all nodes and connections."""
201
+ self.graph.nodes.clear()
202
+ self.graph.connections.clear()
203
+ self._node_id_counter = itertools.count(1)
204
+
205
+ def _node_kind_from_value(self, kind_value: str | None) -> NodeKind | None:
206
+ if not kind_value:
207
+ return None
208
+
209
+ try:
210
+ return NodeKind(kind_value)
211
+ except Exception:
212
+ pass
213
+
214
+ for kind, node_type in self.registry.node_types.items():
215
+ if kind.value == kind_value or node_type.display_name == kind_value:
216
+ return kind
217
+
218
+ return None
219
+
220
+ def create_node(
221
+ self, kind_value: str | None, position: dict[str, Any] | None = None
222
+ ) -> dict[str, Any] | None:
223
+ """Create a new node in the graph and return its Vue node dict."""
224
+ kind = self._node_kind_from_value(kind_value)
225
+ if kind is None:
226
+ return None
227
+
228
+ node_id = self.next_ui_node_id()
229
+ try:
230
+ node_obj = self.registry.create(kind, node_id)
231
+ except KeyError:
232
+ return None
233
+
234
+ if position:
235
+ x = position.get("x")
236
+ y = position.get("y")
237
+ if x is not None:
238
+ try:
239
+ node_obj.x = float(x)
240
+ except (TypeError, ValueError):
241
+ pass
242
+ if y is not None:
243
+ try:
244
+ node_obj.y = float(y)
245
+ except (TypeError, ValueError):
246
+ pass
247
+
248
+ self.graph.add_node(node_obj)
249
+ return self.renderer.to_vue_node(node_obj)
250
+
251
+ async def handle_ui_event(
252
+ self, event: dict[str, Any], canvas: VueFlowCanvas
253
+ ) -> None:
254
+ """Central handler for all Vue Flow events from this tab."""
255
+ event_type = event.get("type")
256
+ payload = event.get("payload")
257
+
258
+ if event_type == "execute_node":
259
+ payload_dict = payload or {}
260
+ node_id = payload_dict.get("id")
261
+ if not node_id:
262
+ return
263
+
264
+ if self.runtime is None:
265
+ self.attach_canvas(canvas)
266
+ else:
267
+ self.runtime.canvas = canvas
268
+
269
+ await self.runtime.execute_node(node_id)
270
+
271
+ elif event_type == "create_node":
272
+ payload_dict = payload or {}
273
+ kind_value = payload_dict.get("kind")
274
+ position = payload_dict.get("position") or {}
275
+ vue_node = self.create_node(kind_value, position)
276
+ if vue_node is not None:
277
+ canvas.add_node(vue_node)
278
+
279
+ else:
280
+ self.controller.handle_event(event)
nodes/text_data.py CHANGED
@@ -1,11 +1,12 @@
1
  from __future__ import annotations
 
2
  from dataclasses import dataclass
3
 
4
  from dataflow.enums import NodeKind, PortDirection
5
  from dataflow.nodes_base import NodeType, NodeInstance
6
  from dataflow.ports import PortSchema
7
- from .text_shared import TextType
8
-
9
 
10
  TextDataNodeType = NodeType(
11
  kind=NodeKind.TEXT_DATA,
@@ -22,7 +23,7 @@ class TextDataNode(NodeInstance):
22
  The text is stored on the "text" output port and edited through the UI.
23
  """
24
 
25
- def process(self) -> None:
26
  """Data nodes do not compute anything, they just expose their current value.
27
 
28
  The UI writes into the "text" output port. Here we simply ensure the port
@@ -34,3 +35,23 @@ class TextDataNode(NodeInstance):
34
  # nothing to compute, but this method must exist so graph.execute
35
  # can safely walk upstream without raising
36
  return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
+
3
  from dataclasses import dataclass
4
 
5
  from dataflow.enums import NodeKind, PortDirection
6
  from dataflow.nodes_base import NodeType, NodeInstance
7
  from dataflow.ports import PortSchema
8
+ from nodes.data_types import TextType
9
+ from nodes.vue_nodes import VueNodeRenderable, VueNodeData
10
 
11
  TextDataNodeType = NodeType(
12
  kind=NodeKind.TEXT_DATA,
 
23
  The text is stored on the "text" output port and edited through the UI.
24
  """
25
 
26
+ async def process(self) -> None:
27
  """Data nodes do not compute anything, they just expose their current value.
28
 
29
  The UI writes into the "text" output port. Here we simply ensure the port
 
35
  # nothing to compute, but this method must exist so graph.execute
36
  # can safely walk upstream without raising
37
  return
38
+
39
+
40
+ class TextDataNodeRenderable(VueNodeRenderable[TextDataNode]):
41
+ def to_vue_node_data(self, node: TextDataNode) -> VueNodeData:
42
+ data = VueNodeData()
43
+
44
+ data.fields.append(
45
+ {
46
+ "name": "text",
47
+ "kind": "textarea",
48
+ "label": "Text",
49
+ "placeholder": "Enter text...",
50
+ }
51
+ )
52
+
53
+ port = node.outputs.get("text") if node.outputs is not None else None
54
+ data.values["text"] = "" if port is None or port.value is None else str(port.value)
55
+
56
+ data.executable = False
57
+ return data
nodes/text_to_image.py CHANGED
@@ -1,29 +1,30 @@
1
  from __future__ import annotations
2
- from dataclasses import dataclass
3
 
4
- import numpy as np
 
5
 
6
- from dataflow.enums import DataTypeId, PortDirection, NodeKind
7
  from dataflow.nodes_base import NodeType, NodeInstance
8
  from dataflow.ports import PortSchema
9
- from dataflow.types import DataType
10
- from .text_shared import TextType
11
-
12
-
13
- ImageType = DataType[np.ndarray](
14
- id=DataTypeId.IMAGE,
15
- name="Image",
16
- py_type=np.ndarray,
17
- encode=lambda a: a.tolist(),
18
- decode=lambda a: np.array(a),
19
- )
20
-
21
 
22
  TextToImageNodeType = NodeType(
23
  kind=NodeKind.TEXT_TO_IMAGE,
24
- display_name="Text to Image",
25
- inputs=[PortSchema(name="text", dtype=TextType, direction=PortDirection.INPUT, capacity=5)],
26
- outputs=[PortSchema(name="image", dtype=ImageType, direction=PortDirection.OUTPUT)],
 
 
 
 
 
 
 
27
  )
28
 
29
 
@@ -31,14 +32,77 @@ TextToImageNodeType = NodeType(
31
  class TextToImageNode(NodeInstance):
32
  # string that UI can bind to; may be url or base64 data
33
  image_src: str | None = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
- def process(self) -> str:
36
- # in a real implementation, you would read the text prompt and generate an image
37
- prompt = self.inputs["text"].value or ""
38
- url = "https://picsum.photos/200"
39
- self.image_src = url
40
 
41
- # placeholder: we do not construct a real image array here
42
- self.outputs["image"].value = None
43
 
44
- return url
 
1
  from __future__ import annotations
 
2
 
3
+ import typing
4
+ from dataclasses import dataclass
5
 
6
+ from dataflow.enums import PortDirection, NodeKind, ConnectMultiplicity
7
  from dataflow.nodes_base import NodeType, NodeInstance
8
  from dataflow.ports import PortSchema
9
+ from services.image.ImageGenerator import ImageGenerator
10
+ from services.registry import get_registry
11
+ from services.services import TaskType
12
+ from . import utils
13
+ from .data_types import ImageType, TextType
14
+ from .vue_nodes import VueNodeRenderable, VueNodeData
 
 
 
 
 
 
15
 
16
  TextToImageNodeType = NodeType(
17
  kind=NodeKind.TEXT_TO_IMAGE,
18
+ display_name="Image Generation",
19
+ inputs=[
20
+ PortSchema(name="text", dtype=TextType, direction=PortDirection.INPUT,
21
+ multiplicity=ConnectMultiplicity.MULTIPLE, capacity=3),
22
+ PortSchema(name="image", dtype=ImageType, direction=PortDirection.INPUT,
23
+ multiplicity=ConnectMultiplicity.MULTIPLE, capacity=3)
24
+ ],
25
+ outputs=[
26
+ PortSchema(name="image", dtype=ImageType, direction=PortDirection.OUTPUT)
27
+ ],
28
  )
29
 
30
 
 
32
  class TextToImageNode(NodeInstance):
33
  # string that UI can bind to; may be url or base64 data
34
  image_src: str | None = None
35
+ error: str | None = None
36
+
37
+ async def process(self) -> None:
38
+ self.error = None
39
+
40
+ text_input = self.inputs["text"]
41
+ image_input = self.inputs["image"]
42
+ image_output = self.outputs["image"]
43
+
44
+ # Handle multiple inputs (concatenation)
45
+ prompt = ""
46
+ if isinstance(text_input.value, list):
47
+ prompt = "\n\n".join([str(v) for v in text_input.value if v])
48
+ elif text_input.value:
49
+ prompt = str(text_input.value)
50
+
51
+ if not prompt or prompt.strip() == "":
52
+ self.error = "Could not generate image! No text input."
53
+ return
54
+
55
+ # optional image inputs
56
+ images = None
57
+ if isinstance(image_input.value, list):
58
+ images = image_input.value
59
+
60
+ registry = get_registry()
61
+ image_service = typing.cast(ImageGenerator, registry.create(TaskType.IMAGE, "google_gemini_image"))
62
+
63
+ def on_progress(value: float, message: str | None):
64
+ # In a fuller implementation, we would emit events to UI here
65
+ pass
66
+
67
+ try:
68
+ import asyncio
69
+ from functools import partial
70
+
71
+ # Run non-blocking
72
+ loop = asyncio.get_running_loop()
73
+ result = await loop.run_in_executor(
74
+ None,
75
+ partial(image_service.generate, prompt, images=images, progress=on_progress)
76
+ )
77
+
78
+ url = utils.encode_image_png(result.image)
79
+ self.image_src = url
80
+
81
+ image_output.value = result.image
82
+ except Exception as e:
83
+ self.error = f"Error: {str(e)}"
84
+ self.image_src = None
85
+
86
+
87
+ class TextToImageNodeRenderable(VueNodeRenderable[TextToImageNode]):
88
+ def to_vue_node_data(self, node: TextToImageNode) -> VueNodeData:
89
+ data = VueNodeData()
90
+
91
+ data.fields.append(
92
+ {
93
+ "name": "image",
94
+ "kind": "image_result",
95
+ "label": "Image",
96
+ "editable": False,
97
+ "placeholder": "No image yet",
98
+ }
99
+ )
100
 
101
+ data.values["image"] = "" if node.image_src is None else str(node.image_src)
102
+ if node.error:
103
+ data.values["error"] = node.error
 
 
104
 
105
+ data.executable = True
106
+ data.processing = False
107
 
108
+ return data
nodes/utils.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import io
3
+
4
+ from PIL import Image
5
+
6
+
7
+ def encode_image_png(img: Image.Image) -> str:
8
+ buffer = io.BytesIO()
9
+ img.save(buffer, format="PNG")
10
+ raw = buffer.getvalue()
11
+ b64 = base64.b64encode(raw).decode("ascii")
12
+ return f"data:image/png;base64,{b64}"
13
+
14
+
15
+ def decode_image_png(data: str) -> Image.Image:
16
+ prefix = "data:image/png;base64,"
17
+ if data.startswith(prefix):
18
+ data = data[len(prefix):]
19
+ raw = base64.b64decode(data.encode("ascii"))
20
+ buffer = io.BytesIO(raw)
21
+ return Image.open(buffer)
nodes/vue_nodes.py CHANGED
@@ -1,12 +1,48 @@
1
  from __future__ import annotations
2
- from dataclasses import dataclass
3
- from typing import Any
 
 
4
 
5
  from dataflow.codecs import node_to_vueflow
6
  from dataflow.nodes_base import NodeInstance
7
 
8
- from .text_data import TextDataNode
9
- from .text_to_image import TextToImageNode
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
 
12
  @dataclass(slots=True)
@@ -16,42 +52,33 @@ class VueNodeRenderer:
16
  App specific node UI details live here, not in the core dataflow package.
17
  """
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  def to_vue_node(self, node: NodeInstance) -> dict[str, Any]:
20
- data_extra: dict[str, Any] = {}
21
- fields: list[dict[str, Any]] = []
22
- values: dict[str, Any] = {}
23
-
24
- if isinstance(node, TextDataNode):
25
- fields.append(
26
- {
27
- "name": "text",
28
- "kind": "text",
29
- "label": "Text",
30
- "placeholder": "Enter text...",
31
- }
32
- )
33
- port = node.outputs.get("text") if node.outputs is not None else None
34
- values["text"] = "" if port is None or port.value is None else str(port.value)
35
- data_extra["executable"] = False
36
-
37
- elif isinstance(node, TextToImageNode):
38
- # processing node: show image preview and an execute button
39
- fields.append(
40
- {
41
- "name": "image",
42
- "kind": "image",
43
- "label": "Image",
44
- "editable": False,
45
- "placeholder": "No image yet",
46
- }
47
- )
48
- values["image"] = "" if node.image_src is None else str(node.image_src)
49
- data_extra["executable"] = True
50
- data_extra["processing"] = False
51
-
52
- if fields:
53
- data_extra["fields"] = fields
54
- if values:
55
- data_extra["values"] = values
56
-
57
- return node_to_vueflow(node, data_extra=data_extra)
 
1
  from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass, field
5
+ from typing import Any, TypeVar, Generic
6
 
7
  from dataflow.codecs import node_to_vueflow
8
  from dataflow.nodes_base import NodeInstance
9
 
10
+ T_Node = TypeVar("T_Node", bound=NodeInstance)
11
+
12
+
13
+ @dataclass(slots=True)
14
+ class VueNodeData:
15
+ """Structured data that describes how a node should be rendered in Vue Flow."""
16
+
17
+ # High level behavior flags
18
+ executable: bool | None = None
19
+ processing: bool | None = None
20
+
21
+ # Form like content
22
+ fields: list[dict[str, Any]] = field(default_factory=list)
23
+ values: dict[str, Any] = field(default_factory=dict)
24
+
25
+ def to_extra_dict(self) -> dict[str, Any]:
26
+ """Convert to the data_extra dict expected by node_to_vueflow."""
27
+ data: dict[str, Any] = {}
28
+
29
+ if self.executable is not None:
30
+ data["executable"] = self.executable
31
+ if self.processing is not None:
32
+ data["processing"] = self.processing
33
+ if self.fields:
34
+ data["fields"] = self.fields
35
+ if self.values:
36
+ data["values"] = self.values
37
+
38
+ return data
39
+
40
+
41
+ class VueNodeRenderable(Generic[T_Node], ABC):
42
+ @abstractmethod
43
+ def to_vue_node_data(self, node: T_Node) -> VueNodeData:
44
+ """Create VueNodeData for a given NodeInstance."""
45
+ raise NotImplementedError
46
 
47
 
48
  @dataclass(slots=True)
 
52
  App specific node UI details live here, not in the core dataflow package.
53
  """
54
 
55
+ _registry: dict[type[NodeInstance], VueNodeRenderable[Any]] = field(default_factory=dict)
56
+
57
+ def register(self, node_type: type[T_Node], renderer: VueNodeRenderable[T_Node]) -> None:
58
+ """Register a renderer for a given node type."""
59
+ self._registry[node_type] = renderer # type: ignore[assignment]
60
+
61
+ def _get_renderer_for_node(self, node: NodeInstance) -> VueNodeRenderable[Any] | None:
62
+ """Find the best matching renderer for the node type, considering its MRO."""
63
+ node_type = type(node)
64
+
65
+ renderer = self._registry.get(node_type)
66
+ if renderer is not None:
67
+ return renderer
68
+
69
+ for base in node_type.mro()[1:]:
70
+ if not issubclass(base, NodeInstance):
71
+ continue
72
+ renderer = self._registry.get(base) # type: ignore[arg-type]
73
+ if renderer is not None:
74
+ return renderer
75
+
76
+ return None
77
+
78
  def to_vue_node(self, node: NodeInstance) -> dict[str, Any]:
79
+ renderer = self._get_renderer_for_node(node)
80
+ if renderer is None:
81
+ return node_to_vueflow(node)
82
+
83
+ node_data = renderer.to_vue_node_data(node) # type: ignore[arg-type]
84
+ return node_to_vueflow(node, data_extra=node_data.to_extra_dict())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
pyproject.toml CHANGED
@@ -4,8 +4,11 @@ version = "0.1.0"
4
  description = "Add your description here"
5
  requires-python = ">=3.12"
6
  dependencies = [
 
7
  "nicegui>=3",
8
- "numpy>=2.3.4",
 
 
9
  ]
10
 
11
  [dependency-groups]
 
4
  description = "Add your description here"
5
  requires-python = ">=3.12"
6
  dependencies = [
7
+ "google-genai>=1.51.0",
8
  "nicegui>=3",
9
+ "pillow>=12.0.0",
10
+ "requests>=2.32.5",
11
+ "uvicorn<=0.35.0",
12
  ]
13
 
14
  [dependency-groups]
services/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from . import image, text
services/exceptions.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+
4
+ class GenerationError(RuntimeError):
5
+ """Base exception raised when a generation backend fails."""
services/image/DummyImageGenerator.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import random
5
+ from collections.abc import Sequence
6
+
7
+ import requests
8
+ from PIL import Image
9
+
10
+ from services.exceptions import GenerationError
11
+ from services.image.ImageGenerationResult import ImageGenerationResult
12
+ from services.image.ImageGenerator import ImageGenerator
13
+ from services.progress import ProgressCallback, call_progress
14
+ from services.registry import register_service
15
+
16
+
17
+ @register_service
18
+ class DummyImageGenerator(ImageGenerator):
19
+ """Dummy image generator backed by picsum.photos."""
20
+ service_id = "dummy_image"
21
+
22
+ @classmethod
23
+ def default_model_name(cls) -> str:
24
+ return "picsum"
25
+
26
+ def close(self) -> None:
27
+ return
28
+
29
+ def generate(
30
+ self,
31
+ prompt: str,
32
+ images: Sequence[Image.Image] | None = None,
33
+ *,
34
+ progress: ProgressCallback | None = None,
35
+ ) -> ImageGenerationResult:
36
+ call_progress(progress, 0.1, "Starting dummy image request")
37
+
38
+ # Let the prompt slightly influence the size to make results somewhat varied
39
+ base_size = 512
40
+ jitter = random.randint(-64, 64)
41
+ size = max(128, base_size + jitter)
42
+ url = f"https://picsum.photos/{size}/{size}"
43
+
44
+ call_progress(progress, 0.4, f"Fetching random image from {url}")
45
+
46
+ try:
47
+ response = requests.get(url, timeout=10)
48
+ except requests.RequestException as exc:
49
+ raise GenerationError("Failed to fetch dummy image from picsum.photos.") from exc
50
+
51
+ if response.status_code != 200:
52
+ raise GenerationError(
53
+ f"picsum.photos returned unexpected status code {response.status_code}."
54
+ )
55
+
56
+ call_progress(progress, 0.7, "Decoding dummy image")
57
+
58
+ try:
59
+ image = Image.open(io.BytesIO(response.content)).convert("RGB")
60
+ except OSError as exc:
61
+ raise GenerationError("Received invalid image data from picsum.photos.") from exc
62
+
63
+ image.load()
64
+
65
+ call_progress(progress, 0.95, "Preparing dummy image result")
66
+
67
+ return ImageGenerationResult(
68
+ provider="dummy",
69
+ model=self.model_name,
70
+ images=[image],
71
+ raw_response={"url": response.url},
72
+ )
services/image/GoogleImageGenerator.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ from typing import Any, Sequence, List
3
+
4
+ from PIL import Image
5
+
6
+ from services.exceptions import GenerationError
7
+ from services.image.ImageGenerationResult import ImageGenerationResult
8
+ from services.image.ImageGenerator import ImageGenerator
9
+ from services.progress import ProgressCallback, call_progress
10
+ from services.registry import register_service
11
+ from services.utils.google_service import DEFAULT_GEMINI_IMAGE_MODEL, \
12
+ iter_response_parts
13
+
14
+
15
+ @register_service
16
+ class GoogleImageGenerator(ImageGenerator):
17
+ """Image generator backed by the Google Gemini API."""
18
+ service_id = "google_gemini_image"
19
+
20
+ def __init__(self, model_name: str | None = None, api_key: str | None = None) -> None:
21
+ super().__init__(model_name=model_name)
22
+ try:
23
+ from google import genai # type: ignore[import-not-found]
24
+ except ModuleNotFoundError as exc:
25
+ raise GenerationError(
26
+ "The 'google-genai' package is required to use the Google image backend."
27
+ ) from exc
28
+
29
+ client_kwargs: dict[str, Any] = {}
30
+ if api_key is not None:
31
+ client_kwargs["api_key"] = api_key
32
+
33
+ # If api_key is omitted, the client will read GOOGLE_API_KEY from the environment
34
+ self._client = genai.Client(**client_kwargs)
35
+
36
+ @classmethod
37
+ def default_model_name(cls) -> str:
38
+ return DEFAULT_GEMINI_IMAGE_MODEL
39
+
40
+ def close(self) -> None:
41
+ # The google client does not expose an explicit close method currently
42
+ return
43
+
44
+ def generate(
45
+ self,
46
+ prompt: str,
47
+ images: Sequence[Image.Image] | None = None,
48
+ *,
49
+ progress: ProgressCallback | None = None,
50
+ ) -> ImageGenerationResult:
51
+ from google.genai import types # type: ignore[import-not-found]
52
+
53
+ call_progress(progress, 0.1, "Encoding inputs")
54
+
55
+ request_parts: List[object] = [prompt]
56
+ images = images or []
57
+
58
+ for image in images:
59
+ buffer = io.BytesIO()
60
+ image.save(buffer, format="PNG")
61
+ image_bytes = buffer.getvalue()
62
+
63
+ part: Any | None = None
64
+
65
+ part_class = getattr(types, "Part", None)
66
+ from_bytes = getattr(part_class, "from_bytes", None)
67
+ if callable(from_bytes):
68
+ part = from_bytes(data=image_bytes, mime_type="image/png")
69
+
70
+ if part is None:
71
+ input_image_class = getattr(types, "InputImage", None)
72
+ if input_image_class is not None:
73
+ part = input_image_class(mime_type="image/png", data=image_bytes)
74
+
75
+ if part is None:
76
+ raise GenerationError(
77
+ "The installed google-genai client does not support image inputs."
78
+ )
79
+
80
+ request_parts.append(part)
81
+
82
+ call_progress(progress, 0.4, "Calling Google Gemini image model")
83
+
84
+ response = self._client.models.generate_content(
85
+ model=self.model_name,
86
+ contents=request_parts,
87
+ )
88
+
89
+ call_progress(progress, 0.7, "Decoding image")
90
+
91
+ generated_images: List[Image.Image] = []
92
+ for part in iter_response_parts(response):
93
+ inline_data = getattr(part, "inline_data", None)
94
+ if inline_data is None:
95
+ continue
96
+
97
+ data: bytes | None = getattr(inline_data, "data", None)
98
+ if data is None:
99
+ continue
100
+
101
+ buffer = io.BytesIO(data)
102
+ try:
103
+ img = Image.open(buffer).convert("RGBA")
104
+ except OSError as exc:
105
+ raise GenerationError("Received invalid image data from Gemini.") from exc
106
+ img.load()
107
+ generated_images.append(img)
108
+
109
+ if not generated_images:
110
+ raise GenerationError("The Google image model did not return any image data.")
111
+
112
+ call_progress(progress, 0.95, "Preparing image result")
113
+
114
+ return ImageGenerationResult(
115
+ provider="google",
116
+ model=self.model_name,
117
+ images=generated_images,
118
+ raw_response=response,
119
+ )
services/image/ImageGenerationResult.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass, field
2
+
3
+ from PIL import Image
4
+
5
+ from services.results import GenerationResult
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class ImageGenerationResult(GenerationResult):
10
+ """Result of an image generation call.
11
+
12
+ Multiple images are supported. The first one is exposed as a convenience
13
+ property "image".
14
+ """
15
+ images: list[Image.Image] = field(default_factory=list)
16
+
17
+ @property
18
+ def image(self) -> Image.Image | None:
19
+ if not self.images:
20
+ return None
21
+ return self.images[0]
services/image/ImageGenerator.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from abc import ABC, abstractmethod
2
+ from typing import Sequence
3
+
4
+ from PIL import Image
5
+
6
+ from services.image.ImageGenerationResult import ImageGenerationResult
7
+ from services.progress import ProgressCallback
8
+ from services.services import GenerationService, TaskType
9
+
10
+
11
+ class ImageGenerator(GenerationService, ABC):
12
+ """Base class for image generation backends."""
13
+
14
+ task_type = TaskType.IMAGE
15
+
16
+ @abstractmethod
17
+ def generate(
18
+ self,
19
+ prompt: str,
20
+ images: Sequence[Image.Image] | None = None,
21
+ *,
22
+ progress: ProgressCallback | None = None,
23
+ ) -> ImageGenerationResult:
24
+ """Generate images from a prompt and optional images."""
25
+ raise NotImplementedError
services/image/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ from services.image.DummyImageGenerator import DummyImageGenerator
2
+ from services.image.GoogleImageGenerator import GoogleImageGenerator
services/progress.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Callable
4
+
5
+ ProgressCallback = Callable[[float, str | None], None]
6
+
7
+
8
+ def call_progress(progress: ProgressCallback | None, value: float, description: str | None = None) -> None:
9
+ """Safely invoke a progress callback if it is provided."""
10
+ if progress is None:
11
+ return
12
+ try:
13
+ progress(value, description)
14
+ except Exception:
15
+ # Progress callbacks should not break generation
16
+ return
services/registry.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Iterable, List, MutableMapping, Type
5
+
6
+ from services.services import TaskType, GenerationService, TService
7
+
8
+
9
+ @dataclass
10
+ class ServiceRegistry:
11
+ """Registry for generation services, grouped by task type."""
12
+ _services: MutableMapping[TaskType, MutableMapping[str, Type[GenerationService]]] = field(
13
+ default_factory=dict
14
+ )
15
+
16
+ def register(self, service_cls: Type[GenerationService]) -> None:
17
+ task_type = service_cls.task_type
18
+ service_id = getattr(service_cls, "service_id", service_cls.__name__)
19
+ bucket = self._services.setdefault(task_type, {})
20
+ if service_id in bucket:
21
+ raise ValueError(
22
+ f"Service id {service_id!r} already registered for task type {task_type}."
23
+ )
24
+ bucket[service_id] = service_cls
25
+
26
+ def get(self, task_type: TaskType, service_id: str) -> Type[GenerationService]:
27
+ bucket = self._services.get(task_type)
28
+ if bucket is None or service_id not in bucket:
29
+ raise KeyError(
30
+ f"No service registered for type {task_type} with id {service_id!r}."
31
+ )
32
+ return bucket[service_id]
33
+
34
+ def create(self, task_type: TaskType, service_id: str, **kwargs) -> GenerationService:
35
+ service_cls = self.get(task_type, service_id)
36
+ return service_cls(**kwargs)
37
+
38
+ def list_ids(self, task_type: TaskType) -> List[str]:
39
+ bucket = self._services.get(task_type, {})
40
+ return sorted(bucket.keys())
41
+
42
+ def iter_services(self, task_type: TaskType) -> Iterable[Type[GenerationService]]:
43
+ bucket = self._services.get(task_type, {})
44
+ return bucket.values()
45
+
46
+
47
+ _default_registry = ServiceRegistry()
48
+
49
+
50
+ def register_service(service_cls: Type[TService]) -> Type[TService]:
51
+ """Class decorator to register a service in the default registry."""
52
+ _default_registry.register(service_cls)
53
+ return service_cls
54
+
55
+
56
+ def get_registry() -> ServiceRegistry:
57
+ return _default_registry
services/results.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # filename: results.py
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class GenerationResult:
10
+ """Common metadata for any generation result."""
11
+ provider: str = ""
12
+ model: str = ""
13
+
14
+ # For debugging or advanced usage
15
+ raw_response: Any | None = None
services/services.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from enum import StrEnum
5
+ from typing import TypeVar
6
+
7
+
8
+ class TaskType(StrEnum):
9
+ TEXT = "text_generation"
10
+ IMAGE = "image_generation"
11
+
12
+
13
+ class GenerationService(ABC):
14
+ """Base class for any generation backend."""
15
+
16
+ # Must be overridden in subclasses
17
+ service_id: str
18
+ task_type: TaskType
19
+
20
+ def __init__(self, model_name: str | None = None) -> None:
21
+ # Uses subclass default model if not provided
22
+ self.model_name = model_name or self.default_model_name()
23
+
24
+ @classmethod
25
+ @abstractmethod
26
+ def default_model_name(cls) -> str:
27
+ """Return the default model name for this service."""
28
+ raise NotImplementedError
29
+
30
+ @property
31
+ def name(self) -> str:
32
+ """Human readable name, by default the service id."""
33
+ return self.service_id
34
+
35
+ @abstractmethod
36
+ def close(self) -> None:
37
+ """Release any resources held by the service."""
38
+ raise NotImplementedError
39
+
40
+
41
+ TService = TypeVar("TService", bound=GenerationService)
services/text/DummyTextGenerator.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Sequence
2
+
3
+ from PIL import Image
4
+
5
+ from services.progress import ProgressCallback, call_progress
6
+ from services.registry import register_service
7
+ from services.text.TextGenerationResult import TextGenerationResult
8
+ from services.text.TextGenerator import TextGenerator
9
+
10
+
11
+ @register_service
12
+ class DummyTextGenerator(TextGenerator):
13
+ """Dummy text generator that simply echoes the prompt."""
14
+ service_id = "dummy_text"
15
+
16
+ @classmethod
17
+ def default_model_name(cls) -> str:
18
+ return "echo"
19
+
20
+ def close(self) -> None:
21
+ return
22
+
23
+ def generate(
24
+ self,
25
+ prompt: str,
26
+ images: Sequence[Image.Image] | None = None,
27
+ *,
28
+ progress: ProgressCallback | None = None,
29
+ ) -> TextGenerationResult:
30
+ call_progress(progress, 0.1, "Starting dummy text generation")
31
+
32
+ images = images or []
33
+ image_info = ""
34
+ if images:
35
+ image_info = f" (with {len(images)} image inputs)"
36
+
37
+ call_progress(progress, 0.6, "Building dummy text response")
38
+
39
+ text = f"[dummy-text]{image_info}: {prompt}"
40
+
41
+ call_progress(progress, 0.95, "Preparing dummy text result")
42
+
43
+ return TextGenerationResult(
44
+ provider="dummy",
45
+ model=self.model_name,
46
+ text=text,
47
+ raw_response=None,
48
+ )
services/text/GoogleTextGenerator.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ from typing import Any, Sequence
3
+
4
+ from PIL import Image
5
+
6
+ from services.exceptions import GenerationError
7
+ from services.progress import call_progress, ProgressCallback
8
+ from services.registry import register_service
9
+ from services.text.TextGenerationResult import TextGenerationResult
10
+ from services.text.TextGenerator import TextGenerator
11
+ from services.utils.google_service import DEFAULT_GEMINI_TEXT_MODEL, iter_response_parts, extract_usage_tokens
12
+
13
+
14
+ @register_service
15
+ class GoogleTextGenerator(TextGenerator):
16
+ """Text generator backed by the Google Gemini API."""
17
+ service_id = "google_gemini_text"
18
+
19
+ def __init__(self, model_name: str | None = None, api_key: str | None = None) -> None:
20
+ super().__init__(model_name=model_name)
21
+ try:
22
+ from google import genai # type: ignore[import-not-found]
23
+ except ModuleNotFoundError as exc:
24
+ raise GenerationError(
25
+ "The 'google-genai' package is required to use the Google text backend."
26
+ ) from exc
27
+
28
+ client_kwargs: dict[str, Any] = {}
29
+ if api_key is not None:
30
+ client_kwargs["api_key"] = api_key
31
+
32
+ self._client = genai.Client(**client_kwargs)
33
+
34
+ @classmethod
35
+ def default_model_name(cls) -> str:
36
+ return DEFAULT_GEMINI_TEXT_MODEL
37
+
38
+ def close(self) -> None:
39
+ # The google client does not expose an explicit close method currently
40
+ return
41
+
42
+ @staticmethod
43
+ def _extract_text(response: Any) -> str:
44
+ text = getattr(response, "text", None)
45
+ if isinstance(text, str) and text.strip():
46
+ return text
47
+
48
+ parts_text: list[str] = []
49
+ for part in iter_response_parts(response):
50
+ value = getattr(part, "text", None)
51
+ if isinstance(value, str):
52
+ parts_text.append(value)
53
+
54
+ if parts_text:
55
+ return "".join(parts_text)
56
+
57
+ candidates = getattr(response, "candidates", None)
58
+ if candidates:
59
+ for candidate in candidates:
60
+ content = getattr(candidate, "content", None)
61
+ if content is None:
62
+ continue
63
+ content_parts = getattr(content, "parts", None) or []
64
+ for part in content_parts:
65
+ value = getattr(part, "text", None)
66
+ if isinstance(value, str):
67
+ parts_text.append(value)
68
+ if parts_text:
69
+ return "".join(parts_text)
70
+
71
+ raise GenerationError("The Google text model did not return any text output.")
72
+
73
+ def generate(
74
+ self,
75
+ prompt: str,
76
+ images: Sequence[Image.Image] | None = None,
77
+ *,
78
+ progress: ProgressCallback | None = None,
79
+ ) -> TextGenerationResult:
80
+ from google.genai import types # type: ignore[import-not-found]
81
+
82
+ call_progress(progress, 0.1, "Encoding inputs")
83
+
84
+ request_parts: list[object] = [prompt]
85
+ images = images or []
86
+
87
+ for image in images:
88
+ buffer = io.BytesIO()
89
+ image.save(buffer, format="PNG")
90
+ image_bytes = buffer.getvalue()
91
+
92
+ part: Any | None = None
93
+
94
+ part_class = getattr(types, "Part", None)
95
+ from_bytes = getattr(part_class, "from_bytes", None)
96
+ if callable(from_bytes):
97
+ part = from_bytes(data=image_bytes, mime_type="image/png")
98
+
99
+ if part is None:
100
+ input_image_class = getattr(types, "InputImage", None)
101
+ if input_image_class is not None:
102
+ part = input_image_class(mime_type="image/png", data=image_bytes)
103
+
104
+ if part is None:
105
+ raise GenerationError(
106
+ "The installed google-genai client does not support image inputs."
107
+ )
108
+
109
+ request_parts.append(part)
110
+
111
+ call_progress(progress, 0.4, "Calling Google Gemini text model")
112
+
113
+ response = self._client.models.generate_content(
114
+ model=self.model_name,
115
+ contents=request_parts,
116
+ )
117
+
118
+ call_progress(progress, 0.7, "Decoding text")
119
+
120
+ output_text = self._extract_text(response)
121
+
122
+ input_tokens, output_tokens = extract_usage_tokens(response)
123
+
124
+ call_progress(progress, 0.95, "Preparing text result")
125
+
126
+ return TextGenerationResult(
127
+ provider="google",
128
+ model=self.model_name,
129
+ text=output_text,
130
+ raw_response=response,
131
+ )
services/text/TextGenerationResult.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+
3
+ from services.results import GenerationResult
4
+
5
+
6
+ @dataclass(slots=True)
7
+ class TextGenerationResult(GenerationResult):
8
+ """Result of a text generation call."""
9
+ text: str = ""
services/text/TextGenerator.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from abc import ABC, abstractmethod
2
+ from typing import Sequence
3
+
4
+ from PIL import Image
5
+
6
+ from services.progress import ProgressCallback
7
+ from services.services import TaskType, GenerationService
8
+ from services.text.TextGenerationResult import TextGenerationResult
9
+
10
+
11
+ class TextGenerator(GenerationService, ABC):
12
+ """Base class for text generation backends."""
13
+
14
+ task_type = TaskType.TEXT
15
+
16
+ @abstractmethod
17
+ def generate(
18
+ self,
19
+ prompt: str,
20
+ images: Sequence[Image.Image] | None = None,
21
+ *,
22
+ progress: ProgressCallback | None = None,
23
+ ) -> TextGenerationResult:
24
+ """Generate text from a prompt and optional images."""
25
+ raise NotImplementedError
services/text/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ from services.text.DummyTextGenerator import DummyTextGenerator
2
+ from services.text.GoogleTextGenerator import GoogleTextGenerator
services/utils/__init__.py ADDED
File without changes
services/utils/google_service.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Any, Optional
5
+
6
+ DEFAULT_GEMINI_TEXT_MODEL = os.getenv("GEMINI_TEXT_MODEL", "gemini-2.5-flash-lite-preview-09-2025")
7
+ DEFAULT_GEMINI_IMAGE_MODEL = os.getenv("GEMINI_IMAGE_MODEL", "gemini-2.5-flash-image-preview")
8
+
9
+
10
+ def iter_response_parts(response: Any):
11
+ """Yield parts from a Gemini response, supporting multiple shapes."""
12
+ parts = getattr(response, "parts", None)
13
+ if parts:
14
+ for part in parts:
15
+ yield part
16
+ return
17
+
18
+ candidates = getattr(response, "candidates", None)
19
+ if candidates:
20
+ for candidate in candidates:
21
+ content = getattr(candidate, "content", None)
22
+ if content is not None:
23
+ content_parts = getattr(content, "parts", None)
24
+ if content_parts:
25
+ for part in content_parts:
26
+ yield part
27
+
28
+
29
+ def extract_usage_tokens(response: Any) -> tuple[Optional[int], Optional[int]]:
30
+ usage_metadata = getattr(response, "usage_metadata", None)
31
+ if usage_metadata is None:
32
+ return None, None
33
+
34
+ input_tokens: Optional[int] = getattr(usage_metadata, "prompt_token_count", None)
35
+ if not isinstance(input_tokens, int):
36
+ input_tokens = getattr(usage_metadata, "input_token_count", None)
37
+
38
+ output_tokens: Optional[int] = getattr(usage_metadata, "candidates_token_count", None)
39
+ if not isinstance(output_tokens, int):
40
+ output_tokens = getattr(usage_metadata, "output_token_count", None)
41
+
42
+ return input_tokens, output_tokens
uv.lock CHANGED
@@ -168,6 +168,15 @@ wheels = [
168
  { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" },
169
  ]
170
 
 
 
 
 
 
 
 
 
 
171
  [[package]]
172
  name = "certifi"
173
  version = "2025.10.5"
@@ -177,6 +186,63 @@ wheels = [
177
  { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" },
178
  ]
179
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  [[package]]
181
  name = "click"
182
  version = "8.3.0"
@@ -329,6 +395,39 @@ wheels = [
329
  { url = "https://files.pythonhosted.org/packages/eb/02/a6b21098b1d5d6249b7c5ab69dde30108a71e4e819d4a9778f1de1d5b70d/fsspec-2025.10.0-py3-none-any.whl", hash = "sha256:7c7712353ae7d875407f97715f0e1ffcc21e33d5b24556cb1e090ae9409ec61d", size = 200966, upload-time = "2025-10-30T14:58:42.53Z" },
330
  ]
331
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  [[package]]
333
  name = "h11"
334
  version = "0.16.0"
@@ -691,8 +790,11 @@ name = "nicegui-graph"
691
  version = "0.1.0"
692
  source = { virtual = "." }
693
  dependencies = [
 
694
  { name = "nicegui" },
695
- { name = "numpy" },
 
 
696
  ]
697
 
698
  [package.dev-dependencies]
@@ -702,76 +804,16 @@ dev = [
702
 
703
  [package.metadata]
704
  requires-dist = [
 
705
  { name = "nicegui", specifier = ">=3" },
706
- { name = "numpy", specifier = ">=2.3.4" },
 
 
707
  ]
708
 
709
  [package.metadata.requires-dev]
710
  dev = [{ name = "huggingface-hub", extras = ["cli"], specifier = ">=1.1.4" }]
711
 
712
- [[package]]
713
- name = "numpy"
714
- version = "2.3.4"
715
- source = { registry = "https://pypi.org/simple" }
716
- sdist = { url = "https://files.pythonhosted.org/packages/b5/f4/098d2270d52b41f1bd7db9fc288aaa0400cb48c2a3e2af6fa365d9720947/numpy-2.3.4.tar.gz", hash = "sha256:a7d018bfedb375a8d979ac758b120ba846a7fe764911a64465fd87b8729f4a6a", size = 20582187, upload-time = "2025-10-15T16:18:11.77Z" }
717
- wheels = [
718
- { url = "https://files.pythonhosted.org/packages/96/7a/02420400b736f84317e759291b8edaeee9dc921f72b045475a9cbdb26b17/numpy-2.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef1b5a3e808bc40827b5fa2c8196151a4c5abe110e1726949d7abddfe5c7ae11", size = 20957727, upload-time = "2025-10-15T16:15:44.9Z" },
719
- { url = "https://files.pythonhosted.org/packages/18/90/a014805d627aa5750f6f0e878172afb6454552da929144b3c07fcae1bb13/numpy-2.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2f91f496a87235c6aaf6d3f3d89b17dba64996abadccb289f48456cff931ca9", size = 14187262, upload-time = "2025-10-15T16:15:47.761Z" },
720
- { url = "https://files.pythonhosted.org/packages/c7/e4/0a94b09abe89e500dc748e7515f21a13e30c5c3fe3396e6d4ac108c25fca/numpy-2.3.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f77e5b3d3da652b474cc80a14084927a5e86a5eccf54ca8ca5cbd697bf7f2667", size = 5115992, upload-time = "2025-10-15T16:15:50.144Z" },
721
- { url = "https://files.pythonhosted.org/packages/88/dd/db77c75b055c6157cbd4f9c92c4458daef0dd9cbe6d8d2fe7f803cb64c37/numpy-2.3.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:8ab1c5f5ee40d6e01cbe96de5863e39b215a4d24e7d007cad56c7184fdf4aeef", size = 6648672, upload-time = "2025-10-15T16:15:52.442Z" },
722
- { url = "https://files.pythonhosted.org/packages/e1/e6/e31b0d713719610e406c0ea3ae0d90760465b086da8783e2fd835ad59027/numpy-2.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77b84453f3adcb994ddbd0d1c5d11db2d6bda1a2b7fd5ac5bd4649d6f5dc682e", size = 14284156, upload-time = "2025-10-15T16:15:54.351Z" },
723
- { url = "https://files.pythonhosted.org/packages/f9/58/30a85127bfee6f108282107caf8e06a1f0cc997cb6b52cdee699276fcce4/numpy-2.3.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4121c5beb58a7f9e6dfdee612cb24f4df5cd4db6e8261d7f4d7450a997a65d6a", size = 16641271, upload-time = "2025-10-15T16:15:56.67Z" },
724
- { url = "https://files.pythonhosted.org/packages/06/f2/2e06a0f2adf23e3ae29283ad96959267938d0efd20a2e25353b70065bfec/numpy-2.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:65611ecbb00ac9846efe04db15cbe6186f562f6bb7e5e05f077e53a599225d16", size = 16059531, upload-time = "2025-10-15T16:15:59.412Z" },
725
- { url = "https://files.pythonhosted.org/packages/b0/e7/b106253c7c0d5dc352b9c8fab91afd76a93950998167fa3e5afe4ef3a18f/numpy-2.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dabc42f9c6577bcc13001b8810d300fe814b4cfbe8a92c873f269484594f9786", size = 18578983, upload-time = "2025-10-15T16:16:01.804Z" },
726
- { url = "https://files.pythonhosted.org/packages/73/e3/04ecc41e71462276ee867ccbef26a4448638eadecf1bc56772c9ed6d0255/numpy-2.3.4-cp312-cp312-win32.whl", hash = "sha256:a49d797192a8d950ca59ee2d0337a4d804f713bb5c3c50e8db26d49666e351dc", size = 6291380, upload-time = "2025-10-15T16:16:03.938Z" },
727
- { url = "https://files.pythonhosted.org/packages/3d/a8/566578b10d8d0e9955b1b6cd5db4e9d4592dd0026a941ff7994cedda030a/numpy-2.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:985f1e46358f06c2a09921e8921e2c98168ed4ae12ccd6e5e87a4f1857923f32", size = 12787999, upload-time = "2025-10-15T16:16:05.801Z" },
728
- { url = "https://files.pythonhosted.org/packages/58/22/9c903a957d0a8071b607f5b1bff0761d6e608b9a965945411f867d515db1/numpy-2.3.4-cp312-cp312-win_arm64.whl", hash = "sha256:4635239814149e06e2cb9db3dd584b2fa64316c96f10656983b8026a82e6e4db", size = 10197412, upload-time = "2025-10-15T16:16:07.854Z" },
729
- { url = "https://files.pythonhosted.org/packages/57/7e/b72610cc91edf138bc588df5150957a4937221ca6058b825b4725c27be62/numpy-2.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c090d4860032b857d94144d1a9976b8e36709e40386db289aaf6672de2a81966", size = 20950335, upload-time = "2025-10-15T16:16:10.304Z" },
730
- { url = "https://files.pythonhosted.org/packages/3e/46/bdd3370dcea2f95ef14af79dbf81e6927102ddf1cc54adc0024d61252fd9/numpy-2.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a13fc473b6db0be619e45f11f9e81260f7302f8d180c49a22b6e6120022596b3", size = 14179878, upload-time = "2025-10-15T16:16:12.595Z" },
731
- { url = "https://files.pythonhosted.org/packages/ac/01/5a67cb785bda60f45415d09c2bc245433f1c68dd82eef9c9002c508b5a65/numpy-2.3.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:3634093d0b428e6c32c3a69b78e554f0cd20ee420dcad5a9f3b2a63762ce4197", size = 5108673, upload-time = "2025-10-15T16:16:14.877Z" },
732
- { url = "https://files.pythonhosted.org/packages/c2/cd/8428e23a9fcebd33988f4cb61208fda832800ca03781f471f3727a820704/numpy-2.3.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:043885b4f7e6e232d7df4f51ffdef8c36320ee9d5f227b380ea636722c7ed12e", size = 6641438, upload-time = "2025-10-15T16:16:16.805Z" },
733
- { url = "https://files.pythonhosted.org/packages/3e/d1/913fe563820f3c6b079f992458f7331278dcd7ba8427e8e745af37ddb44f/numpy-2.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ee6a571d1e4f0ea6d5f22d6e5fbd6ed1dc2b18542848e1e7301bd190500c9d7", size = 14281290, upload-time = "2025-10-15T16:16:18.764Z" },
734
- { url = "https://files.pythonhosted.org/packages/9e/7e/7d306ff7cb143e6d975cfa7eb98a93e73495c4deabb7d1b5ecf09ea0fd69/numpy-2.3.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc8a63918b04b8571789688b2780ab2b4a33ab44bfe8ccea36d3eba51228c953", size = 16636543, upload-time = "2025-10-15T16:16:21.072Z" },
735
- { url = "https://files.pythonhosted.org/packages/47/6a/8cfc486237e56ccfb0db234945552a557ca266f022d281a2f577b98e955c/numpy-2.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:40cc556d5abbc54aabe2b1ae287042d7bdb80c08edede19f0c0afb36ae586f37", size = 16056117, upload-time = "2025-10-15T16:16:23.369Z" },
736
- { url = "https://files.pythonhosted.org/packages/b1/0e/42cb5e69ea901e06ce24bfcc4b5664a56f950a70efdcf221f30d9615f3f3/numpy-2.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ecb63014bb7f4ce653f8be7f1df8cbc6093a5a2811211770f6606cc92b5a78fd", size = 18577788, upload-time = "2025-10-15T16:16:27.496Z" },
737
- { url = "https://files.pythonhosted.org/packages/86/92/41c3d5157d3177559ef0a35da50f0cda7fa071f4ba2306dd36818591a5bc/numpy-2.3.4-cp313-cp313-win32.whl", hash = "sha256:e8370eb6925bb8c1c4264fec52b0384b44f675f191df91cbe0140ec9f0955646", size = 6282620, upload-time = "2025-10-15T16:16:29.811Z" },
738
- { url = "https://files.pythonhosted.org/packages/09/97/fd421e8bc50766665ad35536c2bb4ef916533ba1fdd053a62d96cc7c8b95/numpy-2.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:56209416e81a7893036eea03abcb91c130643eb14233b2515c90dcac963fe99d", size = 12784672, upload-time = "2025-10-15T16:16:31.589Z" },
739
- { url = "https://files.pythonhosted.org/packages/ad/df/5474fb2f74970ca8eb978093969b125a84cc3d30e47f82191f981f13a8a0/numpy-2.3.4-cp313-cp313-win_arm64.whl", hash = "sha256:a700a4031bc0fd6936e78a752eefb79092cecad2599ea9c8039c548bc097f9bc", size = 10196702, upload-time = "2025-10-15T16:16:33.902Z" },
740
- { url = "https://files.pythonhosted.org/packages/11/83/66ac031464ec1767ea3ed48ce40f615eb441072945e98693bec0bcd056cc/numpy-2.3.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:86966db35c4040fdca64f0816a1c1dd8dbd027d90fca5a57e00e1ca4cd41b879", size = 21049003, upload-time = "2025-10-15T16:16:36.101Z" },
741
- { url = "https://files.pythonhosted.org/packages/5f/99/5b14e0e686e61371659a1d5bebd04596b1d72227ce36eed121bb0aeab798/numpy-2.3.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:838f045478638b26c375ee96ea89464d38428c69170360b23a1a50fa4baa3562", size = 14302980, upload-time = "2025-10-15T16:16:39.124Z" },
742
- { url = "https://files.pythonhosted.org/packages/2c/44/e9486649cd087d9fc6920e3fc3ac2aba10838d10804b1e179fb7cbc4e634/numpy-2.3.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d7315ed1dab0286adca467377c8381cd748f3dc92235f22a7dfc42745644a96a", size = 5231472, upload-time = "2025-10-15T16:16:41.168Z" },
743
- { url = "https://files.pythonhosted.org/packages/3e/51/902b24fa8887e5fe2063fd61b1895a476d0bbf46811ab0c7fdf4bd127345/numpy-2.3.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:84f01a4d18b2cc4ade1814a08e5f3c907b079c847051d720fad15ce37aa930b6", size = 6739342, upload-time = "2025-10-15T16:16:43.777Z" },
744
- { url = "https://files.pythonhosted.org/packages/34/f1/4de9586d05b1962acdcdb1dc4af6646361a643f8c864cef7c852bf509740/numpy-2.3.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:817e719a868f0dacde4abdfc5c1910b301877970195db9ab6a5e2c4bd5b121f7", size = 14354338, upload-time = "2025-10-15T16:16:46.081Z" },
745
- { url = "https://files.pythonhosted.org/packages/1f/06/1c16103b425de7969d5a76bdf5ada0804b476fed05d5f9e17b777f1cbefd/numpy-2.3.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85e071da78d92a214212cacea81c6da557cab307f2c34b5f85b628e94803f9c0", size = 16702392, upload-time = "2025-10-15T16:16:48.455Z" },
746
- { url = "https://files.pythonhosted.org/packages/34/b2/65f4dc1b89b5322093572b6e55161bb42e3e0487067af73627f795cc9d47/numpy-2.3.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2ec646892819370cf3558f518797f16597b4e4669894a2ba712caccc9da53f1f", size = 16134998, upload-time = "2025-10-15T16:16:51.114Z" },
747
- { url = "https://files.pythonhosted.org/packages/d4/11/94ec578896cdb973aaf56425d6c7f2aff4186a5c00fac15ff2ec46998b46/numpy-2.3.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:035796aaaddfe2f9664b9a9372f089cfc88bd795a67bd1bfe15e6e770934cf64", size = 18651574, upload-time = "2025-10-15T16:16:53.429Z" },
748
- { url = "https://files.pythonhosted.org/packages/62/b7/7efa763ab33dbccf56dade36938a77345ce8e8192d6b39e470ca25ff3cd0/numpy-2.3.4-cp313-cp313t-win32.whl", hash = "sha256:fea80f4f4cf83b54c3a051f2f727870ee51e22f0248d3114b8e755d160b38cfb", size = 6413135, upload-time = "2025-10-15T16:16:55.992Z" },
749
- { url = "https://files.pythonhosted.org/packages/43/70/aba4c38e8400abcc2f345e13d972fb36c26409b3e644366db7649015f291/numpy-2.3.4-cp313-cp313t-win_amd64.whl", hash = "sha256:15eea9f306b98e0be91eb344a94c0e630689ef302e10c2ce5f7e11905c704f9c", size = 12928582, upload-time = "2025-10-15T16:16:57.943Z" },
750
- { url = "https://files.pythonhosted.org/packages/67/63/871fad5f0073fc00fbbdd7232962ea1ac40eeaae2bba66c76214f7954236/numpy-2.3.4-cp313-cp313t-win_arm64.whl", hash = "sha256:b6c231c9c2fadbae4011ca5e7e83e12dc4a5072f1a1d85a0a7b3ed754d145a40", size = 10266691, upload-time = "2025-10-15T16:17:00.048Z" },
751
- { url = "https://files.pythonhosted.org/packages/72/71/ae6170143c115732470ae3a2d01512870dd16e0953f8a6dc89525696069b/numpy-2.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:81c3e6d8c97295a7360d367f9f8553973651b76907988bb6066376bc2252f24e", size = 20955580, upload-time = "2025-10-15T16:17:02.509Z" },
752
- { url = "https://files.pythonhosted.org/packages/af/39/4be9222ffd6ca8a30eda033d5f753276a9c3426c397bb137d8e19dedd200/numpy-2.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7c26b0b2bf58009ed1f38a641f3db4be8d960a417ca96d14e5b06df1506d41ff", size = 14188056, upload-time = "2025-10-15T16:17:04.873Z" },
753
- { url = "https://files.pythonhosted.org/packages/6c/3d/d85f6700d0a4aa4f9491030e1021c2b2b7421b2b38d01acd16734a2bfdc7/numpy-2.3.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:62b2198c438058a20b6704351b35a1d7db881812d8512d67a69c9de1f18ca05f", size = 5116555, upload-time = "2025-10-15T16:17:07.499Z" },
754
- { url = "https://files.pythonhosted.org/packages/bf/04/82c1467d86f47eee8a19a464c92f90a9bb68ccf14a54c5224d7031241ffb/numpy-2.3.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:9d729d60f8d53a7361707f4b68a9663c968882dd4f09e0d58c044c8bf5faee7b", size = 6643581, upload-time = "2025-10-15T16:17:09.774Z" },
755
- { url = "https://files.pythonhosted.org/packages/0c/d3/c79841741b837e293f48bd7db89d0ac7a4f2503b382b78a790ef1dc778a5/numpy-2.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd0c630cf256b0a7fd9d0a11c9413b42fef5101219ce6ed5a09624f5a65392c7", size = 14299186, upload-time = "2025-10-15T16:17:11.937Z" },
756
- { url = "https://files.pythonhosted.org/packages/e8/7e/4a14a769741fbf237eec5a12a2cbc7a4c4e061852b6533bcb9e9a796c908/numpy-2.3.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5e081bc082825f8b139f9e9fe42942cb4054524598aaeb177ff476cc76d09d2", size = 16638601, upload-time = "2025-10-15T16:17:14.391Z" },
757
- { url = "https://files.pythonhosted.org/packages/93/87/1c1de269f002ff0a41173fe01dcc925f4ecff59264cd8f96cf3b60d12c9b/numpy-2.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15fb27364ed84114438fff8aaf998c9e19adbeba08c0b75409f8c452a8692c52", size = 16074219, upload-time = "2025-10-15T16:17:17.058Z" },
758
- { url = "https://files.pythonhosted.org/packages/cd/28/18f72ee77408e40a76d691001ae599e712ca2a47ddd2c4f695b16c65f077/numpy-2.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:85d9fb2d8cd998c84d13a79a09cc0c1091648e848e4e6249b0ccd7f6b487fa26", size = 18576702, upload-time = "2025-10-15T16:17:19.379Z" },
759
- { url = "https://files.pythonhosted.org/packages/c3/76/95650169b465ececa8cf4b2e8f6df255d4bf662775e797ade2025cc51ae6/numpy-2.3.4-cp314-cp314-win32.whl", hash = "sha256:e73d63fd04e3a9d6bc187f5455d81abfad05660b212c8804bf3b407e984cd2bc", size = 6337136, upload-time = "2025-10-15T16:17:22.886Z" },
760
- { url = "https://files.pythonhosted.org/packages/dc/89/a231a5c43ede5d6f77ba4a91e915a87dea4aeea76560ba4d2bf185c683f0/numpy-2.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:3da3491cee49cf16157e70f607c03a217ea6647b1cea4819c4f48e53d49139b9", size = 12920542, upload-time = "2025-10-15T16:17:24.783Z" },
761
- { url = "https://files.pythonhosted.org/packages/0d/0c/ae9434a888f717c5ed2ff2393b3f344f0ff6f1c793519fa0c540461dc530/numpy-2.3.4-cp314-cp314-win_arm64.whl", hash = "sha256:6d9cd732068e8288dbe2717177320723ccec4fb064123f0caf9bbd90ab5be868", size = 10480213, upload-time = "2025-10-15T16:17:26.935Z" },
762
- { url = "https://files.pythonhosted.org/packages/83/4b/c4a5f0841f92536f6b9592694a5b5f68c9ab37b775ff342649eadf9055d3/numpy-2.3.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:22758999b256b595cf0b1d102b133bb61866ba5ceecf15f759623b64c020c9ec", size = 21052280, upload-time = "2025-10-15T16:17:29.638Z" },
763
- { url = "https://files.pythonhosted.org/packages/3e/80/90308845fc93b984d2cc96d83e2324ce8ad1fd6efea81b324cba4b673854/numpy-2.3.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9cb177bc55b010b19798dc5497d540dea67fd13a8d9e882b2dae71de0cf09eb3", size = 14302930, upload-time = "2025-10-15T16:17:32.384Z" },
764
- { url = "https://files.pythonhosted.org/packages/3d/4e/07439f22f2a3b247cec4d63a713faae55e1141a36e77fb212881f7cda3fb/numpy-2.3.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0f2bcc76f1e05e5ab58893407c63d90b2029908fa41f9f1cc51eecce936c3365", size = 5231504, upload-time = "2025-10-15T16:17:34.515Z" },
765
- { url = "https://files.pythonhosted.org/packages/ab/de/1e11f2547e2fe3d00482b19721855348b94ada8359aef5d40dd57bfae9df/numpy-2.3.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8dc20bde86802df2ed8397a08d793da0ad7a5fd4ea3ac85d757bf5dd4ad7c252", size = 6739405, upload-time = "2025-10-15T16:17:36.128Z" },
766
- { url = "https://files.pythonhosted.org/packages/3b/40/8cd57393a26cebe2e923005db5134a946c62fa56a1087dc7c478f3e30837/numpy-2.3.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e199c087e2aa71c8f9ce1cb7a8e10677dc12457e7cc1be4798632da37c3e86e", size = 14354866, upload-time = "2025-10-15T16:17:38.884Z" },
767
- { url = "https://files.pythonhosted.org/packages/93/39/5b3510f023f96874ee6fea2e40dfa99313a00bf3ab779f3c92978f34aace/numpy-2.3.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85597b2d25ddf655495e2363fe044b0ae999b75bc4d630dc0d886484b03a5eb0", size = 16703296, upload-time = "2025-10-15T16:17:41.564Z" },
768
- { url = "https://files.pythonhosted.org/packages/41/0d/19bb163617c8045209c1996c4e427bccbc4bbff1e2c711f39203c8ddbb4a/numpy-2.3.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:04a69abe45b49c5955923cf2c407843d1c85013b424ae8a560bba16c92fe44a0", size = 16136046, upload-time = "2025-10-15T16:17:43.901Z" },
769
- { url = "https://files.pythonhosted.org/packages/e2/c1/6dba12fdf68b02a21ac411c9df19afa66bed2540f467150ca64d246b463d/numpy-2.3.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e1708fac43ef8b419c975926ce1eaf793b0c13b7356cfab6ab0dc34c0a02ac0f", size = 18652691, upload-time = "2025-10-15T16:17:46.247Z" },
770
- { url = "https://files.pythonhosted.org/packages/f8/73/f85056701dbbbb910c51d846c58d29fd46b30eecd2b6ba760fc8b8a1641b/numpy-2.3.4-cp314-cp314t-win32.whl", hash = "sha256:863e3b5f4d9915aaf1b8ec79ae560ad21f0b8d5e3adc31e73126491bb86dee1d", size = 6485782, upload-time = "2025-10-15T16:17:48.872Z" },
771
- { url = "https://files.pythonhosted.org/packages/17/90/28fa6f9865181cb817c2471ee65678afa8a7e2a1fb16141473d5fa6bacc3/numpy-2.3.4-cp314-cp314t-win_amd64.whl", hash = "sha256:962064de37b9aef801d33bc579690f8bfe6c5e70e29b61783f60bcba838a14d6", size = 13113301, upload-time = "2025-10-15T16:17:50.938Z" },
772
- { url = "https://files.pythonhosted.org/packages/54/23/08c002201a8e7e1f9afba93b97deceb813252d9cfd0d3351caed123dcf97/numpy-2.3.4-cp314-cp314t-win_arm64.whl", hash = "sha256:8b5a9a39c45d852b62693d9b3f3e0fe052541f804296ff401a72a1b60edafb29", size = 10547532, upload-time = "2025-10-15T16:17:53.48Z" },
773
- ]
774
-
775
  [[package]]
776
  name = "orjson"
777
  version = "3.11.4"
@@ -834,6 +876,75 @@ wheels = [
834
  { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
835
  ]
836
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
837
  [[package]]
838
  name = "propcache"
839
  version = "0.4.1"
@@ -918,6 +1029,27 @@ wheels = [
918
  { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" },
919
  ]
920
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
921
  [[package]]
922
  name = "pydantic"
923
  version = "2.12.3"
@@ -1103,6 +1235,33 @@ wheels = [
1103
  { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
1104
  ]
1105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1106
  [[package]]
1107
  name = "shellingham"
1108
  version = "1.5.4"
@@ -1146,6 +1305,15 @@ wheels = [
1146
  { url = "https://files.pythonhosted.org/packages/51/da/545b75d420bb23b5d494b0517757b351963e974e79933f01e05c929f20a6/starlette-0.49.1-py3-none-any.whl", hash = "sha256:d92ce9f07e4a3caa3ac13a79523bd18e3bc0042bb8ff2d759a8e7dd0e1859875", size = 74175, upload-time = "2025-10-28T17:34:09.13Z" },
1147
  ]
1148
 
 
 
 
 
 
 
 
 
 
1149
  [[package]]
1150
  name = "tqdm"
1151
  version = "4.67.1"
@@ -1192,17 +1360,26 @@ wheels = [
1192
  { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
1193
  ]
1194
 
 
 
 
 
 
 
 
 
 
1195
  [[package]]
1196
  name = "uvicorn"
1197
- version = "0.38.0"
1198
  source = { registry = "https://pypi.org/simple" }
1199
  dependencies = [
1200
  { name = "click" },
1201
  { name = "h11" },
1202
  ]
1203
- sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" }
1204
  wheels = [
1205
- { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" },
1206
  ]
1207
 
1208
  [package.optional-dependencies]
 
168
  { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" },
169
  ]
170
 
171
+ [[package]]
172
+ name = "cachetools"
173
+ version = "6.2.2"
174
+ source = { registry = "https://pypi.org/simple" }
175
+ sdist = { url = "https://files.pythonhosted.org/packages/fb/44/ca1675be2a83aeee1886ab745b28cda92093066590233cc501890eb8417a/cachetools-6.2.2.tar.gz", hash = "sha256:8e6d266b25e539df852251cfd6f990b4bc3a141db73b939058d809ebd2590fc6", size = 31571, upload-time = "2025-11-13T17:42:51.465Z" }
176
+ wheels = [
177
+ { url = "https://files.pythonhosted.org/packages/e6/46/eb6eca305c77a4489affe1c5d8f4cae82f285d9addd8de4ec084a7184221/cachetools-6.2.2-py3-none-any.whl", hash = "sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace", size = 11503, upload-time = "2025-11-13T17:42:50.232Z" },
178
+ ]
179
+
180
  [[package]]
181
  name = "certifi"
182
  version = "2025.10.5"
 
186
  { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" },
187
  ]
188
 
189
+ [[package]]
190
+ name = "charset-normalizer"
191
+ version = "3.4.4"
192
+ source = { registry = "https://pypi.org/simple" }
193
+ sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
194
+ wheels = [
195
+ { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
196
+ { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
197
+ { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
198
+ { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
199
+ { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
200
+ { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
201
+ { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
202
+ { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
203
+ { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
204
+ { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
205
+ { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
206
+ { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
207
+ { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
208
+ { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
209
+ { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
210
+ { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
211
+ { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
212
+ { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
213
+ { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
214
+ { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
215
+ { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
216
+ { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
217
+ { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
218
+ { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
219
+ { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
220
+ { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
221
+ { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
222
+ { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
223
+ { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
224
+ { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
225
+ { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
226
+ { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
227
+ { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
228
+ { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
229
+ { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
230
+ { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
231
+ { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
232
+ { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
233
+ { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
234
+ { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
235
+ { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
236
+ { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
237
+ { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
238
+ { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
239
+ { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
240
+ { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
241
+ { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
242
+ { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
243
+ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
244
+ ]
245
+
246
  [[package]]
247
  name = "click"
248
  version = "8.3.0"
 
395
  { url = "https://files.pythonhosted.org/packages/eb/02/a6b21098b1d5d6249b7c5ab69dde30108a71e4e819d4a9778f1de1d5b70d/fsspec-2025.10.0-py3-none-any.whl", hash = "sha256:7c7712353ae7d875407f97715f0e1ffcc21e33d5b24556cb1e090ae9409ec61d", size = 200966, upload-time = "2025-10-30T14:58:42.53Z" },
396
  ]
397
 
398
+ [[package]]
399
+ name = "google-auth"
400
+ version = "2.43.0"
401
+ source = { registry = "https://pypi.org/simple" }
402
+ dependencies = [
403
+ { name = "cachetools" },
404
+ { name = "pyasn1-modules" },
405
+ { name = "rsa" },
406
+ ]
407
+ sdist = { url = "https://files.pythonhosted.org/packages/ff/ef/66d14cf0e01b08d2d51ffc3c20410c4e134a1548fc246a6081eae585a4fe/google_auth-2.43.0.tar.gz", hash = "sha256:88228eee5fc21b62a1b5fe773ca15e67778cb07dc8363adcb4a8827b52d81483", size = 296359, upload-time = "2025-11-06T00:13:36.587Z" }
408
+ wheels = [
409
+ { url = "https://files.pythonhosted.org/packages/6f/d1/385110a9ae86d91cc14c5282c61fe9f4dc41c0b9f7d423c6ad77038c4448/google_auth-2.43.0-py2.py3-none-any.whl", hash = "sha256:af628ba6fa493f75c7e9dbe9373d148ca9f4399b5ea29976519e0a3848eddd16", size = 223114, upload-time = "2025-11-06T00:13:35.209Z" },
410
+ ]
411
+
412
+ [[package]]
413
+ name = "google-genai"
414
+ version = "1.51.0"
415
+ source = { registry = "https://pypi.org/simple" }
416
+ dependencies = [
417
+ { name = "anyio" },
418
+ { name = "google-auth" },
419
+ { name = "httpx" },
420
+ { name = "pydantic" },
421
+ { name = "requests" },
422
+ { name = "tenacity" },
423
+ { name = "typing-extensions" },
424
+ { name = "websockets" },
425
+ ]
426
+ sdist = { url = "https://files.pythonhosted.org/packages/c3/1c/29245699c7c274ed5709b33b6a5192af2d57da5da3d2f189f222d1895336/google_genai-1.51.0.tar.gz", hash = "sha256:596c1ec964b70fec17a6ccfe6ee4edede31022584e8b1d33371d93037c4001b1", size = 258060, upload-time = "2025-11-18T05:32:47.068Z" }
427
+ wheels = [
428
+ { url = "https://files.pythonhosted.org/packages/c6/28/0185dcda66f1994171067cfdb0e44a166450239d5b11b3a8a281dd2da459/google_genai-1.51.0-py3-none-any.whl", hash = "sha256:bfb7d0c6ba48ba9bda539f0d5e69dad827d8735a8b1e4703bafa0a2945d293e1", size = 260483, upload-time = "2025-11-18T05:32:45.938Z" },
429
+ ]
430
+
431
  [[package]]
432
  name = "h11"
433
  version = "0.16.0"
 
790
  version = "0.1.0"
791
  source = { virtual = "." }
792
  dependencies = [
793
+ { name = "google-genai" },
794
  { name = "nicegui" },
795
+ { name = "pillow" },
796
+ { name = "requests" },
797
+ { name = "uvicorn" },
798
  ]
799
 
800
  [package.dev-dependencies]
 
804
 
805
  [package.metadata]
806
  requires-dist = [
807
+ { name = "google-genai", specifier = ">=1.51.0" },
808
  { name = "nicegui", specifier = ">=3" },
809
+ { name = "pillow", specifier = ">=12.0.0" },
810
+ { name = "requests", specifier = ">=2.32.5" },
811
+ { name = "uvicorn", specifier = "<=0.35.0" },
812
  ]
813
 
814
  [package.metadata.requires-dev]
815
  dev = [{ name = "huggingface-hub", extras = ["cli"], specifier = ">=1.1.4" }]
816
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
817
  [[package]]
818
  name = "orjson"
819
  version = "3.11.4"
 
876
  { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
877
  ]
878
 
879
+ [[package]]
880
+ name = "pillow"
881
+ version = "12.0.0"
882
+ source = { registry = "https://pypi.org/simple" }
883
+ sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" }
884
+ wheels = [
885
+ { url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" },
886
+ { url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" },
887
+ { url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" },
888
+ { url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" },
889
+ { url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" },
890
+ { url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" },
891
+ { url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" },
892
+ { url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" },
893
+ { url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" },
894
+ { url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" },
895
+ { url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" },
896
+ { url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" },
897
+ { url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" },
898
+ { url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" },
899
+ { url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" },
900
+ { url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" },
901
+ { url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" },
902
+ { url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" },
903
+ { url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" },
904
+ { url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" },
905
+ { url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" },
906
+ { url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" },
907
+ { url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" },
908
+ { url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" },
909
+ { url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" },
910
+ { url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" },
911
+ { url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" },
912
+ { url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" },
913
+ { url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" },
914
+ { url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" },
915
+ { url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" },
916
+ { url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" },
917
+ { url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" },
918
+ { url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" },
919
+ { url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" },
920
+ { url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" },
921
+ { url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" },
922
+ { url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" },
923
+ { url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" },
924
+ { url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" },
925
+ { url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" },
926
+ { url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" },
927
+ { url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" },
928
+ { url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" },
929
+ { url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" },
930
+ { url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" },
931
+ { url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" },
932
+ { url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" },
933
+ { url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" },
934
+ { url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" },
935
+ { url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" },
936
+ { url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" },
937
+ { url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" },
938
+ { url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" },
939
+ { url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" },
940
+ { url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" },
941
+ { url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" },
942
+ { url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" },
943
+ { url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" },
944
+ { url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" },
945
+ { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" },
946
+ ]
947
+
948
  [[package]]
949
  name = "propcache"
950
  version = "0.4.1"
 
1029
  { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" },
1030
  ]
1031
 
1032
+ [[package]]
1033
+ name = "pyasn1"
1034
+ version = "0.6.1"
1035
+ source = { registry = "https://pypi.org/simple" }
1036
+ sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" }
1037
+ wheels = [
1038
+ { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" },
1039
+ ]
1040
+
1041
+ [[package]]
1042
+ name = "pyasn1-modules"
1043
+ version = "0.4.2"
1044
+ source = { registry = "https://pypi.org/simple" }
1045
+ dependencies = [
1046
+ { name = "pyasn1" },
1047
+ ]
1048
+ sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
1049
+ wheels = [
1050
+ { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
1051
+ ]
1052
+
1053
  [[package]]
1054
  name = "pydantic"
1055
  version = "2.12.3"
 
1235
  { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
1236
  ]
1237
 
1238
+ [[package]]
1239
+ name = "requests"
1240
+ version = "2.32.5"
1241
+ source = { registry = "https://pypi.org/simple" }
1242
+ dependencies = [
1243
+ { name = "certifi" },
1244
+ { name = "charset-normalizer" },
1245
+ { name = "idna" },
1246
+ { name = "urllib3" },
1247
+ ]
1248
+ sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
1249
+ wheels = [
1250
+ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
1251
+ ]
1252
+
1253
+ [[package]]
1254
+ name = "rsa"
1255
+ version = "4.9.1"
1256
+ source = { registry = "https://pypi.org/simple" }
1257
+ dependencies = [
1258
+ { name = "pyasn1" },
1259
+ ]
1260
+ sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
1261
+ wheels = [
1262
+ { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
1263
+ ]
1264
+
1265
  [[package]]
1266
  name = "shellingham"
1267
  version = "1.5.4"
 
1305
  { url = "https://files.pythonhosted.org/packages/51/da/545b75d420bb23b5d494b0517757b351963e974e79933f01e05c929f20a6/starlette-0.49.1-py3-none-any.whl", hash = "sha256:d92ce9f07e4a3caa3ac13a79523bd18e3bc0042bb8ff2d759a8e7dd0e1859875", size = 74175, upload-time = "2025-10-28T17:34:09.13Z" },
1306
  ]
1307
 
1308
+ [[package]]
1309
+ name = "tenacity"
1310
+ version = "9.1.2"
1311
+ source = { registry = "https://pypi.org/simple" }
1312
+ sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" }
1313
+ wheels = [
1314
+ { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" },
1315
+ ]
1316
+
1317
  [[package]]
1318
  name = "tqdm"
1319
  version = "4.67.1"
 
1360
  { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
1361
  ]
1362
 
1363
+ [[package]]
1364
+ name = "urllib3"
1365
+ version = "2.5.0"
1366
+ source = { registry = "https://pypi.org/simple" }
1367
+ sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
1368
+ wheels = [
1369
+ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
1370
+ ]
1371
+
1372
  [[package]]
1373
  name = "uvicorn"
1374
+ version = "0.35.0"
1375
  source = { registry = "https://pypi.org/simple" }
1376
  dependencies = [
1377
  { name = "click" },
1378
  { name = "h11" },
1379
  ]
1380
+ sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" }
1381
  wheels = [
1382
+ { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" },
1383
  ]
1384
 
1385
  [package.optional-dependencies]