Upload folder via script
Browse files- .gitignore +1 -0
- dataflow/codecs.py +19 -0
- dataflow/enums.py +1 -0
- dataflow/graph.py +39 -11
- dataflow/nodes_base.py +11 -4
- dataflow/types.py +3 -0
- dataflow/ui/vueflow_canvas.vue +372 -21
- main.py +115 -127
- nodes/data_types.py +23 -0
- nodes/image_data.py +57 -0
- nodes/runtime.py +10 -3
- nodes/session.py +280 -0
- nodes/text_data.py +24 -3
- nodes/text_to_image.py +90 -26
- nodes/utils.py +21 -0
- nodes/vue_nodes.py +69 -42
- pyproject.toml +4 -1
- services/__init__.py +1 -0
- services/exceptions.py +5 -0
- services/image/DummyImageGenerator.py +72 -0
- services/image/GoogleImageGenerator.py +119 -0
- services/image/ImageGenerationResult.py +21 -0
- services/image/ImageGenerator.py +25 -0
- services/image/__init__.py +2 -0
- services/progress.py +16 -0
- services/registry.py +57 -0
- services/results.py +15 -0
- services/services.py +41 -0
- services/text/DummyTextGenerator.py +48 -0
- services/text/GoogleTextGenerator.py +131 -0
- services/text/TextGenerationResult.py +9 -0
- services/text/TextGenerator.py +25 -0
- services/text/__init__.py +2 -0
- services/utils/__init__.py +0 -0
- services/utils/google_service.py +42 -0
- uv.lock +245 -68
.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 |
-
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
for inp in node.all_inputs():
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
else:
|
| 49 |
-
|
|
|
|
| 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
|
| 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 |
-
<
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
</div>
|
| 67 |
|
| 68 |
<div class="vf-node-content">
|
|
@@ -83,7 +128,63 @@
|
|
| 83 |
/>
|
| 84 |
</div>
|
| 85 |
|
| 86 |
-
<!--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
| 211 |
return [];
|
| 212 |
}
|
| 213 |
},
|
| 214 |
initialEdges: {
|
| 215 |
-
type: Array,
|
|
|
|
| 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
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1057 |
font-size: 11px;
|
| 1058 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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 |
-
|
| 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 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 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 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 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 .
|
| 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
|
|
|
|
| 5 |
|
| 6 |
-
from dataflow.enums import
|
| 7 |
from dataflow.nodes_base import NodeType, NodeInstance
|
| 8 |
from dataflow.ports import PortSchema
|
| 9 |
-
from
|
| 10 |
-
from .
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 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="
|
| 25 |
-
inputs=[
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
url = "https://picsum.photos/200"
|
| 39 |
-
self.image_src = url
|
| 40 |
|
| 41 |
-
|
| 42 |
-
|
| 43 |
|
| 44 |
-
return
|
|
|
|
| 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 |
-
|
| 3 |
-
from
|
|
|
|
|
|
|
| 4 |
|
| 5 |
from dataflow.codecs import node_to_vueflow
|
| 6 |
from dataflow.nodes_base import NodeInstance
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 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 |
-
"
|
|
|
|
|
|
|
| 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 = "
|
|
|
|
|
|
|
| 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 = "
|
|
|
|
|
|
|
| 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.
|
| 1198 |
source = { registry = "https://pypi.org/simple" }
|
| 1199 |
dependencies = [
|
| 1200 |
{ name = "click" },
|
| 1201 |
{ name = "h11" },
|
| 1202 |
]
|
| 1203 |
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
| 1204 |
wheels = [
|
| 1205 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 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]
|