qtype 0.0.4__py3-none-any.whl → 0.0.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qtype/commands/convert.py +18 -5
- qtype/commands/generate.py +16 -8
- qtype/commands/run.py +6 -83
- qtype/commands/serve.py +73 -0
- qtype/commands/validate.py +18 -8
- qtype/commands/visualize.py +87 -0
- qtype/commons/generate.py +9 -4
- qtype/converters/tools_from_module.py +69 -134
- qtype/converters/types.py +47 -1
- qtype/dsl/base_types.py +0 -1
- qtype/dsl/custom_types.py +73 -0
- qtype/dsl/document.py +27 -3
- qtype/dsl/domain_types.py +3 -0
- qtype/dsl/model.py +60 -73
- qtype/dsl/validator.py +20 -0
- qtype/interpreter/api.py +49 -13
- qtype/interpreter/chat/chat_api.py +237 -0
- qtype/interpreter/chat/file_conversions.py +57 -0
- qtype/interpreter/chat/vercel.py +314 -0
- qtype/interpreter/conversions.py +2 -0
- qtype/interpreter/steps/llm_inference.py +44 -19
- qtype/interpreter/streaming_helpers.py +123 -0
- qtype/interpreter/typing.py +29 -10
- qtype/interpreter/ui/404/index.html +1 -0
- qtype/interpreter/ui/404.html +1 -0
- qtype/interpreter/ui/_next/static/chunks/4bd1b696-cf72ae8a39fa05aa.js +1 -0
- qtype/interpreter/ui/_next/static/chunks/736-7fc606e244fedcb1.js +36 -0
- qtype/interpreter/ui/_next/static/chunks/964-ed4ab073db645007.js +1 -0
- qtype/interpreter/ui/_next/static/chunks/app/_not-found/page-e110d2a9d0a83d82.js +1 -0
- qtype/interpreter/ui/_next/static/chunks/app/layout-107b589eb751bfb7.js +1 -0
- qtype/interpreter/ui/_next/static/chunks/app/page-c72e847e888e549d.js +1 -0
- qtype/interpreter/ui/_next/static/chunks/ba12c10f-22556063851a6df2.js +1 -0
- qtype/interpreter/ui/_next/static/chunks/framework-7c95b8e5103c9e90.js +1 -0
- qtype/interpreter/ui/_next/static/chunks/main-6d261b6c5d6fb6c2.js +1 -0
- qtype/interpreter/ui/_next/static/chunks/main-app-6fc6346bc8f7f163.js +1 -0
- qtype/interpreter/ui/_next/static/chunks/pages/_app-0a0020ddd67f79cf.js +1 -0
- qtype/interpreter/ui/_next/static/chunks/pages/_error-03529f2c21436739.js +1 -0
- qtype/interpreter/ui/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- qtype/interpreter/ui/_next/static/chunks/webpack-2218571d248e99d2.js +1 -0
- qtype/interpreter/ui/_next/static/css/d4ad601c4774485e.css +3 -0
- qtype/interpreter/ui/_next/static/dBTVLoSkoaoznQv-yROk9/_buildManifest.js +1 -0
- qtype/interpreter/ui/_next/static/dBTVLoSkoaoznQv-yROk9/_ssgManifest.js +1 -0
- qtype/interpreter/ui/_next/static/media/569ce4b8f30dc480-s.p.woff2 +0 -0
- qtype/interpreter/ui/_next/static/media/747892c23ea88013-s.woff2 +0 -0
- qtype/interpreter/ui/_next/static/media/8d697b304b401681-s.woff2 +0 -0
- qtype/interpreter/ui/_next/static/media/93f479601ee12b01-s.p.woff2 +0 -0
- qtype/interpreter/ui/_next/static/media/9610d9e46709d722-s.woff2 +0 -0
- qtype/interpreter/ui/_next/static/media/ba015fad6dcf6784-s.woff2 +0 -0
- qtype/interpreter/ui/favicon.ico +0 -0
- qtype/interpreter/ui/file.svg +1 -0
- qtype/interpreter/ui/globe.svg +1 -0
- qtype/interpreter/ui/index.html +1 -0
- qtype/interpreter/ui/index.txt +22 -0
- qtype/interpreter/ui/next.svg +1 -0
- qtype/interpreter/ui/vercel.svg +1 -0
- qtype/interpreter/ui/window.svg +1 -0
- qtype/loader.py +57 -8
- qtype/semantic/generate.py +17 -5
- qtype/semantic/model.py +16 -24
- qtype/semantic/visualize.py +485 -0
- {qtype-0.0.4.dist-info → qtype-0.0.6.dist-info}/METADATA +28 -20
- qtype-0.0.6.dist-info/RECORD +91 -0
- qtype-0.0.4.dist-info/RECORD +0 -50
- {qtype-0.0.4.dist-info → qtype-0.0.6.dist-info}/WHEEL +0 -0
- {qtype-0.0.4.dist-info → qtype-0.0.6.dist-info}/entry_points.txt +0 -0
- {qtype-0.0.4.dist-info → qtype-0.0.6.dist-info}/licenses/LICENSE +0 -0
- {qtype-0.0.4.dist-info → qtype-0.0.6.dist-info}/top_level.txt +0 -0
qtype/dsl/model.py
CHANGED
|
@@ -3,9 +3,15 @@ from __future__ import annotations
|
|
|
3
3
|
import inspect
|
|
4
4
|
from abc import ABC
|
|
5
5
|
from enum import Enum
|
|
6
|
-
from typing import Any, Type, Union
|
|
7
|
-
|
|
8
|
-
from pydantic import
|
|
6
|
+
from typing import Any, Literal, Type, Union
|
|
7
|
+
|
|
8
|
+
from pydantic import (
|
|
9
|
+
BaseModel,
|
|
10
|
+
Field,
|
|
11
|
+
RootModel,
|
|
12
|
+
ValidationInfo,
|
|
13
|
+
model_validator,
|
|
14
|
+
)
|
|
9
15
|
|
|
10
16
|
import qtype.dsl.domain_types as domain_types
|
|
11
17
|
from qtype.dsl.base_types import PrimitiveTypeEnum, StrictBaseModel
|
|
@@ -26,7 +32,9 @@ DOMAIN_CLASSES = {
|
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
|
|
29
|
-
def _resolve_variable_type(
|
|
35
|
+
def _resolve_variable_type(
|
|
36
|
+
parsed_type: Any, custom_type_registry: dict[str, Type[BaseModel]]
|
|
37
|
+
) -> Any:
|
|
30
38
|
"""Resolve a type string to its corresponding PrimitiveTypeEnum or return as is."""
|
|
31
39
|
# If the type is already resolved or is a structured definition, pass it through.
|
|
32
40
|
if not isinstance(parsed_type, str):
|
|
@@ -44,12 +52,16 @@ def _resolve_variable_type(parsed_type: Any) -> Any:
|
|
|
44
52
|
if parsed_type in DOMAIN_CLASSES:
|
|
45
53
|
return DOMAIN_CLASSES[parsed_type]
|
|
46
54
|
|
|
55
|
+
# Check the registry of dynamically created custom types
|
|
56
|
+
if parsed_type in custom_type_registry:
|
|
57
|
+
return custom_type_registry[parsed_type]
|
|
58
|
+
|
|
47
59
|
# If it's not a primitive or a known domain entity, return it as a string.
|
|
48
60
|
# This assumes it might be a reference ID to another custom type.
|
|
49
61
|
return parsed_type
|
|
50
62
|
|
|
51
63
|
|
|
52
|
-
class Variable(
|
|
64
|
+
class Variable(BaseModel):
|
|
53
65
|
"""Schema for a variable that can serve as input, output, or parameter within the DSL."""
|
|
54
66
|
|
|
55
67
|
id: str = Field(
|
|
@@ -58,67 +70,45 @@ class Variable(StrictBaseModel):
|
|
|
58
70
|
)
|
|
59
71
|
type: VariableType | str = Field(
|
|
60
72
|
...,
|
|
61
|
-
description=(
|
|
73
|
+
description=(
|
|
74
|
+
"Type of data expected or produced. Either a CustomType or domain specific type."
|
|
75
|
+
),
|
|
62
76
|
)
|
|
63
77
|
|
|
64
78
|
@model_validator(mode="before")
|
|
65
79
|
@classmethod
|
|
66
|
-
def resolve_type(cls, data: Any) -> Any:
|
|
67
|
-
|
|
68
|
-
|
|
80
|
+
def resolve_type(cls, data: Any, info: ValidationInfo) -> Any:
|
|
81
|
+
"""
|
|
82
|
+
This validator runs during the main validation pass. It uses the
|
|
83
|
+
context to resolve string-based type references.
|
|
84
|
+
"""
|
|
85
|
+
if (
|
|
86
|
+
isinstance(data, dict)
|
|
87
|
+
and "type" in data
|
|
88
|
+
and isinstance(data["type"], str)
|
|
89
|
+
):
|
|
90
|
+
# Get the registry of custom types from the validation context.
|
|
91
|
+
custom_types = (info.context or {}).get("custom_types", {})
|
|
92
|
+
resolved = _resolve_variable_type(data["type"], custom_types)
|
|
93
|
+
# {'id': 'user_message', 'type': 'ChatMessage'}
|
|
94
|
+
data["type"] = resolved
|
|
69
95
|
return data
|
|
70
96
|
|
|
71
97
|
|
|
72
|
-
class
|
|
73
|
-
|
|
74
|
-
kind: StructuralTypeEnum = Field(
|
|
75
|
-
...,
|
|
76
|
-
description="The kind of structure this type represents (object/array).",
|
|
77
|
-
)
|
|
78
|
-
description: str | None = Field(
|
|
79
|
-
None, description="A description of what this type represents."
|
|
80
|
-
)
|
|
98
|
+
class CustomType(StrictBaseModel):
|
|
99
|
+
"""A simple declaration of a custom data type by the user."""
|
|
81
100
|
|
|
101
|
+
id: str
|
|
102
|
+
description: str | None = None
|
|
103
|
+
properties: dict[str, str]
|
|
82
104
|
|
|
83
|
-
class ObjectTypeDefinition(TypeDefinitionBase):
|
|
84
|
-
kind: StructuralTypeEnum = StructuralTypeEnum.object
|
|
85
|
-
properties: dict[str, VariableType | str] | None = Field(
|
|
86
|
-
None, description="Defines the nested properties."
|
|
87
|
-
)
|
|
88
105
|
|
|
89
|
-
@model_validator(mode="after")
|
|
90
|
-
def resolve_type(self) -> "ObjectTypeDefinition":
|
|
91
|
-
"""Resolve the type string to its corresponding PrimitiveTypeEnum."""
|
|
92
|
-
# Pydantic doesn't properly handle enums as strings in model validation,
|
|
93
|
-
if self.properties:
|
|
94
|
-
for key, value in self.properties.items():
|
|
95
|
-
if isinstance(value, str):
|
|
96
|
-
self.properties[key] = _resolve_variable_type(value)
|
|
97
|
-
return self
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
class ArrayTypeDefinition(TypeDefinitionBase):
|
|
101
|
-
kind: StructuralTypeEnum = StructuralTypeEnum.array
|
|
102
|
-
type: VariableType | str = Field(
|
|
103
|
-
..., description="The type of items in the array."
|
|
104
|
-
)
|
|
105
|
-
|
|
106
|
-
@model_validator(mode="before")
|
|
107
|
-
@classmethod
|
|
108
|
-
def resolve_type(cls, data: Any) -> Any:
|
|
109
|
-
if isinstance(data, dict) and "type" in data:
|
|
110
|
-
# If the type is a string, resolve it to PrimitiveTypeEnum or Domain Entity class.
|
|
111
|
-
data["type"] = _resolve_variable_type(data["type"]) # type: ignore
|
|
112
|
-
return data
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
TypeDefinition = ObjectTypeDefinition | ArrayTypeDefinition
|
|
116
106
|
VariableType = (
|
|
117
107
|
PrimitiveTypeEnum
|
|
118
|
-
| TypeDefinition
|
|
119
108
|
| Type[Embedding]
|
|
120
109
|
| Type[ChatMessage]
|
|
121
110
|
| Type[ChatContent]
|
|
111
|
+
| Type[BaseModel]
|
|
122
112
|
)
|
|
123
113
|
|
|
124
114
|
|
|
@@ -321,6 +311,12 @@ class Flow(Step):
|
|
|
321
311
|
the first and last step, respectively.
|
|
322
312
|
"""
|
|
323
313
|
|
|
314
|
+
description: str | None = Field(
|
|
315
|
+
default=None, description="Optional description of the flow."
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
mode: Literal["Complete", "Chat"] = "Complete"
|
|
319
|
+
|
|
324
320
|
steps: list[StepType | str] = Field(
|
|
325
321
|
default_factory=list, description="List of steps or step IDs."
|
|
326
322
|
)
|
|
@@ -422,7 +418,15 @@ class TelemetrySink(StrictBaseModel):
|
|
|
422
418
|
|
|
423
419
|
|
|
424
420
|
class Application(StrictBaseModel):
|
|
425
|
-
"""Defines a QType application
|
|
421
|
+
"""Defines a complete QType application specification.
|
|
422
|
+
|
|
423
|
+
An Application is the top-level container of the entire
|
|
424
|
+
program in a QType YAML file. It serves as the blueprint for your
|
|
425
|
+
AI-powered application, containing all the models, flows, tools, data sources,
|
|
426
|
+
and configuration needed to run your program. Think of it as the main entry
|
|
427
|
+
point that ties together all components into a cohesive,
|
|
428
|
+
executable system.
|
|
429
|
+
"""
|
|
426
430
|
|
|
427
431
|
id: str = Field(..., description="Unique ID of the application.")
|
|
428
432
|
description: str | None = Field(
|
|
@@ -437,7 +441,7 @@ class Application(StrictBaseModel):
|
|
|
437
441
|
models: list[ModelType] | None = Field(
|
|
438
442
|
default=None, description="List of models used in this application."
|
|
439
443
|
)
|
|
440
|
-
types: list[
|
|
444
|
+
types: list[CustomType] | None = Field(
|
|
441
445
|
default=None,
|
|
442
446
|
description="List of custom types defined in this application.",
|
|
443
447
|
)
|
|
@@ -540,24 +544,7 @@ class VectorSearch(Search):
|
|
|
540
544
|
]
|
|
541
545
|
|
|
542
546
|
if self.outputs is None:
|
|
543
|
-
self.outputs = [
|
|
544
|
-
Variable(
|
|
545
|
-
id=f"{self.id}.results",
|
|
546
|
-
type=ArrayTypeDefinition(
|
|
547
|
-
id=f"{self.id}.SearchResult",
|
|
548
|
-
type=ObjectTypeDefinition(
|
|
549
|
-
id="SearchResult",
|
|
550
|
-
description="Result of a search operation.",
|
|
551
|
-
properties={
|
|
552
|
-
"score": PrimitiveTypeEnum.number,
|
|
553
|
-
"id": PrimitiveTypeEnum.text,
|
|
554
|
-
"document": PrimitiveTypeEnum.text,
|
|
555
|
-
},
|
|
556
|
-
),
|
|
557
|
-
description=None,
|
|
558
|
-
),
|
|
559
|
-
)
|
|
560
|
-
]
|
|
547
|
+
self.outputs = [Variable(id=f"{self.id}.results", type=Embedding)]
|
|
561
548
|
return self
|
|
562
549
|
|
|
563
550
|
|
|
@@ -639,10 +626,10 @@ class ToolList(RootModel[list[ToolType]]):
|
|
|
639
626
|
root: list[ToolType]
|
|
640
627
|
|
|
641
628
|
|
|
642
|
-
class TypeList(RootModel[list[
|
|
629
|
+
class TypeList(RootModel[list[CustomType]]):
|
|
643
630
|
"""Schema for a standalone list of type definitions."""
|
|
644
631
|
|
|
645
|
-
root: list[
|
|
632
|
+
root: list[CustomType]
|
|
646
633
|
|
|
647
634
|
|
|
648
635
|
class VariableList(RootModel[list[Variable]]):
|
qtype/dsl/validator.py
CHANGED
|
@@ -436,4 +436,24 @@ def validate(
|
|
|
436
436
|
for obj_id, obj in lookup_map.items()
|
|
437
437
|
}
|
|
438
438
|
|
|
439
|
+
# If any chat flow doesn't have an input variable that is a chat message, raise an error.
|
|
440
|
+
for flow in dsl_application.flows or []:
|
|
441
|
+
if flow.mode == "Chat":
|
|
442
|
+
inputs = flow.inputs or []
|
|
443
|
+
if not any(
|
|
444
|
+
input_var.type == qtype.dsl.domain_types.ChatMessage
|
|
445
|
+
for input_var in inputs
|
|
446
|
+
if isinstance(input_var, dsl.Variable)
|
|
447
|
+
):
|
|
448
|
+
raise QTypeValidationError(
|
|
449
|
+
f"Chat flow {flow.id} must have at least one input variable of type ChatMessage."
|
|
450
|
+
)
|
|
451
|
+
if (
|
|
452
|
+
len(flow.outputs) != 1
|
|
453
|
+
or flow.outputs[0].type != qtype.dsl.domain_types.ChatMessage
|
|
454
|
+
): # type: ignore
|
|
455
|
+
raise QTypeValidationError(
|
|
456
|
+
f"Chat flow {flow.id} must have exactly one output variable of type ChatMessage."
|
|
457
|
+
)
|
|
458
|
+
|
|
439
459
|
return dsl_application
|
qtype/interpreter/api.py
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
from
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
2
4
|
|
|
3
5
|
from fastapi import FastAPI, HTTPException
|
|
6
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
7
|
+
from fastapi.staticfiles import StaticFiles
|
|
4
8
|
|
|
5
9
|
from qtype.interpreter.flow import execute_flow
|
|
6
10
|
from qtype.interpreter.typing import (
|
|
@@ -23,19 +27,54 @@ class APIExecutor:
|
|
|
23
27
|
self.host = host
|
|
24
28
|
self.port = port
|
|
25
29
|
|
|
26
|
-
def create_app(
|
|
30
|
+
def create_app(
|
|
31
|
+
self,
|
|
32
|
+
name: str | None = None,
|
|
33
|
+
ui_enabled: bool = True,
|
|
34
|
+
fast_api_args: dict | None = None,
|
|
35
|
+
) -> FastAPI:
|
|
27
36
|
"""Create FastAPI app with dynamic endpoints."""
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
37
|
+
if fast_api_args is None:
|
|
38
|
+
fast_api_args = {
|
|
39
|
+
"docs_url": "/docs",
|
|
40
|
+
"redoc_url": "/redoc",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
app = FastAPI(title=name or "QType API", **fast_api_args)
|
|
44
|
+
|
|
45
|
+
# Serve static UI files if they exist
|
|
46
|
+
if ui_enabled:
|
|
47
|
+
# Add CORS middleware only for localhost development
|
|
48
|
+
if self.host in ("localhost", "127.0.0.1", "0.0.0.0"):
|
|
49
|
+
app.add_middleware(
|
|
50
|
+
CORSMiddleware,
|
|
51
|
+
allow_origins=["*"],
|
|
52
|
+
allow_credentials=True,
|
|
53
|
+
allow_methods=["*"],
|
|
54
|
+
allow_headers=["*"],
|
|
55
|
+
)
|
|
56
|
+
ui_dir = Path(__file__).parent / "ui"
|
|
57
|
+
if ui_dir.exists():
|
|
58
|
+
app.mount(
|
|
59
|
+
"/ui",
|
|
60
|
+
StaticFiles(directory=str(ui_dir), html=True),
|
|
61
|
+
name="ui",
|
|
62
|
+
)
|
|
33
63
|
|
|
34
64
|
flows = self.definition.flows if self.definition.flows else []
|
|
35
65
|
|
|
36
66
|
# Dynamically generate POST endpoints for each flow
|
|
37
67
|
for flow in flows:
|
|
38
|
-
|
|
68
|
+
if flow.mode == "Chat":
|
|
69
|
+
# For chat, we can create a single endpoint
|
|
70
|
+
# that handles the chat interactions
|
|
71
|
+
from qtype.interpreter.chat.chat_api import (
|
|
72
|
+
create_chat_flow_endpoint,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
create_chat_flow_endpoint(app, flow)
|
|
76
|
+
else:
|
|
77
|
+
self._create_flow_endpoint(app, flow)
|
|
39
78
|
|
|
40
79
|
return app
|
|
41
80
|
|
|
@@ -49,11 +88,9 @@ class APIExecutor:
|
|
|
49
88
|
|
|
50
89
|
# Create the endpoint function with proper model binding
|
|
51
90
|
def execute_flow_endpoint(request: RequestModel) -> ResponseModel: # type: ignore
|
|
52
|
-
"""Execute the specific flow with provided inputs."""
|
|
53
91
|
try:
|
|
54
92
|
# Make a copy of the flow to avoid modifying the original
|
|
55
|
-
# TODO:
|
|
56
|
-
# TODO: Store memory and session info in a cache to enable this kind of stateful communication.
|
|
93
|
+
# TODO: Use session to ensure memory is not used across requests.
|
|
57
94
|
flow_copy = flow.model_copy(deep=True)
|
|
58
95
|
# Set input values on the flow variables
|
|
59
96
|
if flow_copy.inputs:
|
|
@@ -98,7 +135,6 @@ class APIExecutor:
|
|
|
98
135
|
app.post(
|
|
99
136
|
f"/flows/{flow_id}",
|
|
100
137
|
tags=["flow"],
|
|
101
|
-
|
|
102
|
-
description=f"Execute the '{flow_id}' flow with the provided input parameters.",
|
|
138
|
+
description=flow.description or "Execute a flow",
|
|
103
139
|
response_model=ResponseModel,
|
|
104
140
|
)(execute_flow_endpoint)
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import uuid
|
|
5
|
+
from collections.abc import Generator
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
from fastapi.responses import StreamingResponse
|
|
10
|
+
|
|
11
|
+
from qtype.dsl.base_types import PrimitiveTypeEnum
|
|
12
|
+
from qtype.dsl.domain_types import ChatContent, ChatMessage, MessageRole
|
|
13
|
+
from qtype.interpreter.chat.file_conversions import file_to_content
|
|
14
|
+
from qtype.interpreter.chat.vercel import (
|
|
15
|
+
ChatRequest,
|
|
16
|
+
ErrorChunk,
|
|
17
|
+
FinishChunk,
|
|
18
|
+
StartChunk,
|
|
19
|
+
TextDeltaChunk,
|
|
20
|
+
TextEndChunk,
|
|
21
|
+
TextStartChunk,
|
|
22
|
+
UIMessage,
|
|
23
|
+
)
|
|
24
|
+
from qtype.interpreter.flow import execute_flow
|
|
25
|
+
from qtype.interpreter.streaming_helpers import create_streaming_generator
|
|
26
|
+
from qtype.semantic.model import Flow
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _ui_request_to_domain_type(request: ChatRequest) -> list[ChatMessage]:
|
|
30
|
+
"""
|
|
31
|
+
Convert a ChatRequest to domain-specific ChatMessages.
|
|
32
|
+
|
|
33
|
+
Processes all UI messages from the AI SDK UI/React request format.
|
|
34
|
+
Returns the full conversation history for context.
|
|
35
|
+
"""
|
|
36
|
+
if not request.messages:
|
|
37
|
+
raise ValueError("No messages provided in request.")
|
|
38
|
+
|
|
39
|
+
# Convert each UIMessage to a domain-specific ChatMessage
|
|
40
|
+
return [
|
|
41
|
+
_ui_message_to_domain_type(message) for message in request.messages
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _ui_message_to_domain_type(message: UIMessage) -> ChatMessage:
|
|
46
|
+
"""
|
|
47
|
+
Convert a UIMessage to a domain-specific ChatMessage.
|
|
48
|
+
|
|
49
|
+
Creates one block for each part in the message content.
|
|
50
|
+
"""
|
|
51
|
+
blocks = []
|
|
52
|
+
|
|
53
|
+
for part in message.parts:
|
|
54
|
+
if part.type == "text":
|
|
55
|
+
blocks.append(
|
|
56
|
+
ChatContent(type=PrimitiveTypeEnum.text, content=part.text)
|
|
57
|
+
)
|
|
58
|
+
elif part.type == "reasoning":
|
|
59
|
+
blocks.append(
|
|
60
|
+
ChatContent(type=PrimitiveTypeEnum.text, content=part.text)
|
|
61
|
+
)
|
|
62
|
+
elif part.type == "file":
|
|
63
|
+
blocks.append(
|
|
64
|
+
file_to_content(part.url) # type: ignore
|
|
65
|
+
)
|
|
66
|
+
elif part.type.startswith("tool-"):
|
|
67
|
+
raise NotImplementedError(
|
|
68
|
+
"Tool call part handling is not implemented yet."
|
|
69
|
+
)
|
|
70
|
+
elif part.type == "dynamic-tool":
|
|
71
|
+
raise NotImplementedError(
|
|
72
|
+
"Dynamic tool part handling is not implemented yet."
|
|
73
|
+
)
|
|
74
|
+
elif part.type == "step-start":
|
|
75
|
+
# Step boundaries might not need content blocks
|
|
76
|
+
continue
|
|
77
|
+
elif part.type in ["source-url", "source-document"]:
|
|
78
|
+
raise NotImplementedError(
|
|
79
|
+
"Source part handling is not implemented yet."
|
|
80
|
+
)
|
|
81
|
+
elif part.type.startswith("data-"):
|
|
82
|
+
raise NotImplementedError(
|
|
83
|
+
"Data part handling is not implemented yet."
|
|
84
|
+
)
|
|
85
|
+
else:
|
|
86
|
+
# Log unknown part types for debugging
|
|
87
|
+
raise ValueError(f"Unknown part type: {part.type}")
|
|
88
|
+
|
|
89
|
+
# If no blocks were created, raise an error
|
|
90
|
+
if not blocks:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
"No valid content blocks created from UIMessage parts."
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return ChatMessage(
|
|
96
|
+
role=MessageRole(message.role),
|
|
97
|
+
blocks=blocks,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def create_chat_flow_endpoint(app: FastAPI, flow: Flow) -> None:
|
|
102
|
+
"""
|
|
103
|
+
Create a chat endpoint for the given Flow.
|
|
104
|
+
|
|
105
|
+
This creates an endpoint at /flows/{flow_id}/chat that follows the
|
|
106
|
+
AI SDK UI/React request format and responds with streaming data.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
app: The FastAPI application instance
|
|
110
|
+
flow: The Flow to create an endpoint for
|
|
111
|
+
"""
|
|
112
|
+
flow_id = flow.id
|
|
113
|
+
|
|
114
|
+
async def handle_chat_data(request: ChatRequest) -> StreamingResponse:
|
|
115
|
+
"""Handle chat requests for the specific flow."""
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
# Convert AI SDK UI request to domain ChatMessages
|
|
119
|
+
messages = _ui_request_to_domain_type(request)
|
|
120
|
+
if not len(messages):
|
|
121
|
+
raise ValueError("No input messages received")
|
|
122
|
+
|
|
123
|
+
# Pop the last message as the current input
|
|
124
|
+
current_input = messages.pop()
|
|
125
|
+
if current_input.role != MessageRole.user:
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"Unexpected input {current_input} from non user role: {current_input.role}"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
flow_copy = flow.model_copy(deep=True)
|
|
131
|
+
|
|
132
|
+
input_variable = [
|
|
133
|
+
var for var in flow_copy.inputs if var.type == ChatMessage
|
|
134
|
+
][0]
|
|
135
|
+
input_variable.value = current_input
|
|
136
|
+
|
|
137
|
+
# Pass conversation context to flow execution for memory population
|
|
138
|
+
execution_kwargs: Any = {
|
|
139
|
+
"session_id": request.id, # Use request ID as session identifier
|
|
140
|
+
"conversation_history": messages,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
# Create a streaming generator for the flow execution
|
|
144
|
+
stream_generator, result_future = create_streaming_generator(
|
|
145
|
+
execute_flow, flow_copy, **execution_kwargs
|
|
146
|
+
)
|
|
147
|
+
except Exception as e:
|
|
148
|
+
error_chunk = ErrorChunk(errorText=str(e))
|
|
149
|
+
response = StreamingResponse(
|
|
150
|
+
[
|
|
151
|
+
f"data: {error_chunk.model_dump_json(by_alias=True, exclude_none=True)}\n\n"
|
|
152
|
+
],
|
|
153
|
+
media_type="text/plain; charset=utf-8",
|
|
154
|
+
)
|
|
155
|
+
response.headers["x-vercel-ai-ui-message-stream"] = "v1"
|
|
156
|
+
return response
|
|
157
|
+
|
|
158
|
+
# Create generator that formats messages according to AI SDK UI streaming protocol
|
|
159
|
+
def vercel_ai_formatter() -> Generator[str, None, None]:
|
|
160
|
+
"""Format stream data according to AI SDK UI streaming protocol."""
|
|
161
|
+
|
|
162
|
+
# Send start chunk
|
|
163
|
+
start_chunk = StartChunk(messageId=str(uuid.uuid4())) # type: ignore
|
|
164
|
+
yield f"data: {start_chunk.model_dump_json(by_alias=True, exclude_none=True)}\n\n"
|
|
165
|
+
|
|
166
|
+
# Track text content for proper streaming
|
|
167
|
+
text_id = str(uuid.uuid4())
|
|
168
|
+
text_started = False
|
|
169
|
+
|
|
170
|
+
for step, message in stream_generator:
|
|
171
|
+
if isinstance(message, ChatMessage):
|
|
172
|
+
# Convert ChatMessage to text content
|
|
173
|
+
content = " ".join(
|
|
174
|
+
[
|
|
175
|
+
block.content
|
|
176
|
+
for block in message.blocks
|
|
177
|
+
if hasattr(block, "content") and block.content
|
|
178
|
+
]
|
|
179
|
+
)
|
|
180
|
+
if content.strip():
|
|
181
|
+
# Start text block if not started
|
|
182
|
+
if not text_started:
|
|
183
|
+
text_start = TextStartChunk(id=text_id)
|
|
184
|
+
yield f"data: {text_start.model_dump_json(by_alias=True, exclude_none=True)}\n\n"
|
|
185
|
+
text_started = True
|
|
186
|
+
|
|
187
|
+
# Send text delta
|
|
188
|
+
text_delta = TextDeltaChunk(id=text_id, delta=content)
|
|
189
|
+
yield f"data: {text_delta.model_dump_json(by_alias=True, exclude_none=True)}\n\n"
|
|
190
|
+
else:
|
|
191
|
+
# Handle other message types as text deltas
|
|
192
|
+
text_content = str(message)
|
|
193
|
+
if text_content.strip():
|
|
194
|
+
# Start text block if not started
|
|
195
|
+
if not text_started:
|
|
196
|
+
text_start = TextStartChunk(id=text_id)
|
|
197
|
+
yield f"data: {text_start.model_dump_json(by_alias=True, exclude_none=True)}\n\n"
|
|
198
|
+
text_started = True
|
|
199
|
+
|
|
200
|
+
# Send text delta
|
|
201
|
+
text_delta = TextDeltaChunk(
|
|
202
|
+
id=text_id, delta=text_content
|
|
203
|
+
)
|
|
204
|
+
yield f"data: {text_delta.model_dump_json(by_alias=True, exclude_none=True)}\n\n"
|
|
205
|
+
|
|
206
|
+
# End text block if it was started
|
|
207
|
+
if text_started:
|
|
208
|
+
text_end = TextEndChunk(id=text_id)
|
|
209
|
+
yield f"data: {text_end.model_dump_json(by_alias=True, exclude_none=True)}\n\n"
|
|
210
|
+
|
|
211
|
+
# Send finish chunk
|
|
212
|
+
try:
|
|
213
|
+
result_future.result(timeout=5.0)
|
|
214
|
+
finish_chunk = FinishChunk()
|
|
215
|
+
yield f"data: {finish_chunk.model_dump_json(by_alias=True, exclude_none=True)}\n\n"
|
|
216
|
+
except Exception as e:
|
|
217
|
+
# Send error
|
|
218
|
+
error_chunk = ErrorChunk(errorText=str(e))
|
|
219
|
+
logging.error(
|
|
220
|
+
f"Error during flow execution: {e}", exc_info=True
|
|
221
|
+
)
|
|
222
|
+
yield f"data: {error_chunk.model_dump_json(by_alias=True, exclude_none=True)}\n\n"
|
|
223
|
+
|
|
224
|
+
response = StreamingResponse(
|
|
225
|
+
vercel_ai_formatter(), media_type="text/plain; charset=utf-8"
|
|
226
|
+
)
|
|
227
|
+
response.headers["x-vercel-ai-ui-message-stream"] = "v1"
|
|
228
|
+
return response
|
|
229
|
+
|
|
230
|
+
# Add the endpoint to the FastAPI app
|
|
231
|
+
app.post(
|
|
232
|
+
f"/flows/{flow_id}/chat",
|
|
233
|
+
tags=["chat"],
|
|
234
|
+
summary=f"Chat with {flow_id} flow",
|
|
235
|
+
description=flow.description,
|
|
236
|
+
response_class=StreamingResponse,
|
|
237
|
+
)(handle_chat_data)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
|
|
3
|
+
import magic
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
from qtype.dsl.base_types import PrimitiveTypeEnum
|
|
7
|
+
from qtype.dsl.domain_types import ChatContent
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def file_to_content(url: str) -> ChatContent:
|
|
11
|
+
"""
|
|
12
|
+
Convert a file URL to a ChatContent block.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
url: The URL of the file.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
A ChatContent block with type 'file' and the file URL as content.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
# Get the bytes from the url.
|
|
22
|
+
if url.startswith("data:"):
|
|
23
|
+
# strip the `data:` prefix and decode the base64 content
|
|
24
|
+
b64content = url[len("data:") :].split(",", 1)[1]
|
|
25
|
+
content = base64.b64decode(b64content)
|
|
26
|
+
else:
|
|
27
|
+
content = requests.get(url).content
|
|
28
|
+
|
|
29
|
+
# Determine the mime type using python-magic
|
|
30
|
+
media_type = magic.from_buffer(content, mime=True)
|
|
31
|
+
|
|
32
|
+
if media_type.startswith("image/"):
|
|
33
|
+
return ChatContent(
|
|
34
|
+
type=PrimitiveTypeEnum.image, content=content, mime_type=media_type
|
|
35
|
+
)
|
|
36
|
+
elif media_type.startswith("video/"):
|
|
37
|
+
return ChatContent(
|
|
38
|
+
type=PrimitiveTypeEnum.video, content=content, mime_type=media_type
|
|
39
|
+
)
|
|
40
|
+
elif media_type.startswith("audio/"):
|
|
41
|
+
return ChatContent(
|
|
42
|
+
type=PrimitiveTypeEnum.audio, content=content, mime_type=media_type
|
|
43
|
+
)
|
|
44
|
+
elif media_type.startswith("text/"):
|
|
45
|
+
return ChatContent(
|
|
46
|
+
type=PrimitiveTypeEnum.text,
|
|
47
|
+
content=content.decode("utf-8"),
|
|
48
|
+
mime_type=media_type,
|
|
49
|
+
)
|
|
50
|
+
elif media_type.startswith("document/"):
|
|
51
|
+
return ChatContent(
|
|
52
|
+
type=PrimitiveTypeEnum.file, content=content, mime_type=media_type
|
|
53
|
+
)
|
|
54
|
+
else:
|
|
55
|
+
return ChatContent(
|
|
56
|
+
type=PrimitiveTypeEnum.bytes, content=content, mime_type=media_type
|
|
57
|
+
)
|