chainlit-utils 0.0.3__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.
- chainlit_utils/__init__.py +1 -0
- chainlit_utils/auth.py +12 -0
- chainlit_utils/chat.py +59 -0
- chainlit_utils/chat_settings.py +137 -0
- chainlit_utils/database.py +139 -0
- chainlit_utils/migrations/0001_chainlit_data_layer.sql +132 -0
- chainlit_utils/migrations/0002_thread_tags.sql +4 -0
- chainlit_utils/migrations/0003_chainlit_2_1_0_command.sql +5 -0
- chainlit_utils/migrations/0004_chainlit_2_3_0_default_open.sql +5 -0
- chainlit_utils/migrations/0005_chainlit_2_9_4_modes.sql +5 -0
- chainlit_utils/py.typed +1 -0
- chainlit_utils/settings.py +30 -0
- chainlit_utils-0.0.3.dist-info/METADATA +125 -0
- chainlit_utils-0.0.3.dist-info/RECORD +17 -0
- chainlit_utils-0.0.3.dist-info/WHEEL +4 -0
- chainlit_utils-0.0.3.dist-info/entry_points.txt +3 -0
- chainlit_utils-0.0.3.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Reusable helpers for Chainlit applications."""
|
chainlit_utils/auth.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Authenticated-user helpers for Chainlit applications."""
|
|
2
|
+
|
|
3
|
+
import chainlit as cl
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def authenticated_user_identifier() -> str:
|
|
7
|
+
"""Return the unique identifier of the current authenticated user."""
|
|
8
|
+
user = cl.user_session.get("user")
|
|
9
|
+
identifier = getattr(user, "identifier", None)
|
|
10
|
+
if not isinstance(identifier, str) or not identifier:
|
|
11
|
+
raise RuntimeError("Chainlit request has no authenticated user.")
|
|
12
|
+
return identifier
|
chainlit_utils/chat.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Message and chat-history helpers for Chainlit applications."""
|
|
2
|
+
|
|
3
|
+
from typing import cast
|
|
4
|
+
|
|
5
|
+
import chainlit as cl
|
|
6
|
+
from chainlit.types import ThreadDict
|
|
7
|
+
from openai.types.chat import ChatCompletionMessageParam
|
|
8
|
+
|
|
9
|
+
from chainlit_utils.settings import settings
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def mark_model_context_excluded(message: cl.Message | cl.AskActionMessage) -> None:
|
|
13
|
+
"""Keep a UI-only message out of subsequent model requests."""
|
|
14
|
+
message.metadata = {
|
|
15
|
+
**(message.metadata or {}),
|
|
16
|
+
settings.MODEL_CONTEXT_EXCLUDED_KEY: True,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def send_ui_message(content: str) -> None:
|
|
21
|
+
"""Send a UI-only message that must not become model context."""
|
|
22
|
+
message = cl.Message(content=content)
|
|
23
|
+
mark_model_context_excluded(message)
|
|
24
|
+
await message.send()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def mark_persisted_errors_excluded(thread: ThreadDict) -> None:
|
|
28
|
+
"""Preserve Chainlit's persisted error flag through context restore."""
|
|
29
|
+
for step in thread.get("steps", []):
|
|
30
|
+
if "message" not in step.get("type", "") or not step.get("isError"):
|
|
31
|
+
continue
|
|
32
|
+
metadata = step.get("metadata")
|
|
33
|
+
step["metadata"] = {
|
|
34
|
+
**(metadata if isinstance(metadata, dict) else {}),
|
|
35
|
+
settings.MODEL_CONTEXT_EXCLUDED_KEY: True,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def text_only_chat_messages() -> list[ChatCompletionMessageParam]:
|
|
40
|
+
"""Return Chainlit's role/content projection of the current chat.
|
|
41
|
+
|
|
42
|
+
This transcript is not a lossless OpenAI protocol ledger. Chainlit's native
|
|
43
|
+
projection does not retain fields such as ``tool_calls`` or ``tool_call_id``.
|
|
44
|
+
UI-only, failed, and cancelled assistant messages are omitted.
|
|
45
|
+
"""
|
|
46
|
+
chainlit_messages = cl.chat_context.get()
|
|
47
|
+
openai_messages = cl.chat_context.to_openai()
|
|
48
|
+
return [
|
|
49
|
+
cast(ChatCompletionMessageParam, openai_message)
|
|
50
|
+
for chainlit_message, openai_message in zip(
|
|
51
|
+
chainlit_messages,
|
|
52
|
+
openai_messages,
|
|
53
|
+
strict=True,
|
|
54
|
+
)
|
|
55
|
+
if not chainlit_message.is_error
|
|
56
|
+
and not (chainlit_message.metadata or {}).get(
|
|
57
|
+
settings.MODEL_CONTEXT_EXCLUDED_KEY
|
|
58
|
+
)
|
|
59
|
+
]
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Convert simple JSON Schema settings into Chainlit controls and JSON."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from chainlit.input_widget import InputWidget, Select, Switch, TextInput
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SettingsSerializationError(ValueError):
|
|
11
|
+
"""Raised when selected chat settings cannot be serialized."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def settings_widgets(
|
|
15
|
+
json_schema: Mapping[str, Any],
|
|
16
|
+
defaults: Mapping[str, Any],
|
|
17
|
+
candidates: Mapping[str, Any] | None = None,
|
|
18
|
+
) -> list[InputWidget]:
|
|
19
|
+
"""Build widgets for direct scalar properties with concrete defaults."""
|
|
20
|
+
properties = json_schema.get("properties")
|
|
21
|
+
if not isinstance(properties, dict):
|
|
22
|
+
return []
|
|
23
|
+
|
|
24
|
+
candidates = candidates or {}
|
|
25
|
+
widgets: list[InputWidget] = []
|
|
26
|
+
for name, default in defaults.items():
|
|
27
|
+
widget = _widget_for_property(
|
|
28
|
+
name,
|
|
29
|
+
properties.get(name),
|
|
30
|
+
default,
|
|
31
|
+
candidates,
|
|
32
|
+
)
|
|
33
|
+
if widget is not None:
|
|
34
|
+
widgets.append(widget)
|
|
35
|
+
return widgets
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def serialize_settings(
|
|
39
|
+
defaults: Mapping[str, Any] | None,
|
|
40
|
+
values: Mapping[str, Any] | None,
|
|
41
|
+
*,
|
|
42
|
+
max_length: int | None = None,
|
|
43
|
+
) -> str | None:
|
|
44
|
+
"""Serialize non-default settings as compact JSON, or return ``None``."""
|
|
45
|
+
if max_length is not None and max_length < 0:
|
|
46
|
+
raise ValueError("max_length cannot be negative.")
|
|
47
|
+
if defaults is None or values is None:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
changed = {
|
|
51
|
+
name: values[name]
|
|
52
|
+
for name, default in defaults.items()
|
|
53
|
+
if name in values
|
|
54
|
+
and not (type(values[name]) is type(default) and values[name] == default)
|
|
55
|
+
}
|
|
56
|
+
if not changed:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
encoded = json.dumps(
|
|
61
|
+
changed,
|
|
62
|
+
allow_nan=False,
|
|
63
|
+
ensure_ascii=False,
|
|
64
|
+
separators=(",", ":"),
|
|
65
|
+
)
|
|
66
|
+
except (TypeError, ValueError) as exc:
|
|
67
|
+
raise SettingsSerializationError(
|
|
68
|
+
"The selected settings cannot be encoded as JSON."
|
|
69
|
+
) from exc
|
|
70
|
+
if max_length is not None and len(encoded) > max_length:
|
|
71
|
+
raise SettingsSerializationError(
|
|
72
|
+
f"The selected settings exceed the {max_length}-character limit."
|
|
73
|
+
)
|
|
74
|
+
return encoded
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _widget_for_property(
|
|
78
|
+
name: str,
|
|
79
|
+
schema: Any,
|
|
80
|
+
default: Any,
|
|
81
|
+
candidates: Mapping[str, Any],
|
|
82
|
+
) -> InputWidget | None:
|
|
83
|
+
"""Build one widget from the small schema subset Chainlit can represent."""
|
|
84
|
+
if not name or not isinstance(schema, dict):
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
label = str(schema.get("title") or name.replace("_", " ").title())
|
|
88
|
+
description = _optional_text(schema.get("description"))
|
|
89
|
+
schema_type = schema.get("type")
|
|
90
|
+
candidate = candidates.get(name)
|
|
91
|
+
|
|
92
|
+
if schema_type == "boolean":
|
|
93
|
+
if type(default) is not bool:
|
|
94
|
+
return None
|
|
95
|
+
initial = candidate if type(candidate) is bool else default
|
|
96
|
+
return Switch(
|
|
97
|
+
id=name,
|
|
98
|
+
label=label,
|
|
99
|
+
description=description,
|
|
100
|
+
initial=initial,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if schema_type != "string" or not isinstance(default, str):
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
if "enum" in schema:
|
|
107
|
+
enum = schema["enum"]
|
|
108
|
+
if (
|
|
109
|
+
not isinstance(enum, list)
|
|
110
|
+
or not enum
|
|
111
|
+
or any(not isinstance(value, str) for value in enum)
|
|
112
|
+
or len(set(enum)) != len(enum)
|
|
113
|
+
or default not in enum
|
|
114
|
+
):
|
|
115
|
+
return None
|
|
116
|
+
initial = (
|
|
117
|
+
candidate if isinstance(candidate, str) and candidate in enum else default
|
|
118
|
+
)
|
|
119
|
+
return Select(
|
|
120
|
+
id=name,
|
|
121
|
+
label=label,
|
|
122
|
+
description=description,
|
|
123
|
+
values=enum,
|
|
124
|
+
initial_value=initial,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
initial = candidate if isinstance(candidate, str) else default
|
|
128
|
+
return TextInput(
|
|
129
|
+
id=name,
|
|
130
|
+
label=label,
|
|
131
|
+
description=description,
|
|
132
|
+
initial=initial,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _optional_text(value: Any) -> str | None:
|
|
137
|
+
return value if isinstance(value, str) else None
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Versioned PostgreSQL migrations for Chainlit conversation persistence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from hashlib import sha256
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import asyncpg
|
|
14
|
+
|
|
15
|
+
from chainlit_utils.settings import settings
|
|
16
|
+
|
|
17
|
+
MIGRATIONS_DIR = Path(__file__).with_name("migrations")
|
|
18
|
+
MIGRATION_LOCK_ID = 0x434C5554494C5301
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ChainlitMigrationError(RuntimeError):
|
|
24
|
+
"""Raised when Chainlit migrations are missing, unknown, or changed."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Migration:
|
|
29
|
+
"""A checksum-protected SQL migration."""
|
|
30
|
+
|
|
31
|
+
version: str
|
|
32
|
+
checksum: str
|
|
33
|
+
sql: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_migrations(directory: Path | None = None) -> tuple[Migration, ...]:
|
|
37
|
+
"""Load the bundled SQL migrations in version order."""
|
|
38
|
+
selected_directory = MIGRATIONS_DIR if directory is None else directory
|
|
39
|
+
migrations = []
|
|
40
|
+
for path in sorted(selected_directory.glob("*.sql")):
|
|
41
|
+
sql = path.read_text(encoding="utf-8")
|
|
42
|
+
migrations.append(
|
|
43
|
+
Migration(
|
|
44
|
+
version=path.stem,
|
|
45
|
+
checksum=sha256(sql.encode()).hexdigest(),
|
|
46
|
+
sql=sql,
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
if not migrations:
|
|
50
|
+
raise ChainlitMigrationError(
|
|
51
|
+
f"No Chainlit migrations found in {selected_directory}."
|
|
52
|
+
)
|
|
53
|
+
return tuple(migrations)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def apply_migrations(
|
|
57
|
+
connection: asyncpg.Connection,
|
|
58
|
+
migrations: tuple[Migration, ...] | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Apply each pending migration atomically and reject migration drift."""
|
|
61
|
+
quoted_table = _quoted_identifier(settings.MIGRATIONS_TABLE)
|
|
62
|
+
await connection.execute("SELECT pg_advisory_lock($1)", MIGRATION_LOCK_ID)
|
|
63
|
+
try:
|
|
64
|
+
await connection.execute(
|
|
65
|
+
f"""
|
|
66
|
+
CREATE TABLE IF NOT EXISTS {quoted_table} (
|
|
67
|
+
version TEXT PRIMARY KEY,
|
|
68
|
+
checksum TEXT NOT NULL,
|
|
69
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
70
|
+
)
|
|
71
|
+
"""
|
|
72
|
+
)
|
|
73
|
+
rows = await connection.fetch(f"SELECT version, checksum FROM {quoted_table}")
|
|
74
|
+
applied = {row["version"]: row["checksum"] for row in rows}
|
|
75
|
+
selected_migrations = load_migrations() if migrations is None else migrations
|
|
76
|
+
known_versions = {migration.version for migration in selected_migrations}
|
|
77
|
+
unknown_versions = sorted(set(applied) - known_versions)
|
|
78
|
+
if unknown_versions:
|
|
79
|
+
joined_versions = ", ".join(unknown_versions)
|
|
80
|
+
raise ChainlitMigrationError(
|
|
81
|
+
"Database contains Chainlit migrations unknown to this release: "
|
|
82
|
+
f"{joined_versions}."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
for migration in selected_migrations:
|
|
86
|
+
applied_checksum = applied.get(migration.version)
|
|
87
|
+
if applied_checksum == migration.checksum:
|
|
88
|
+
continue
|
|
89
|
+
if applied_checksum is not None:
|
|
90
|
+
raise ChainlitMigrationError(
|
|
91
|
+
f"Applied Chainlit migration {migration.version!r} has changed."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
async with connection.transaction():
|
|
95
|
+
# Bundled migrations are trusted assets and may contain multiple SQL
|
|
96
|
+
# statements. asyncpg executes the complete script as one command.
|
|
97
|
+
await connection.execute(migration.sql)
|
|
98
|
+
await connection.execute(
|
|
99
|
+
f"""
|
|
100
|
+
INSERT INTO {quoted_table} (version, checksum)
|
|
101
|
+
VALUES ($1, $2)
|
|
102
|
+
""",
|
|
103
|
+
migration.version,
|
|
104
|
+
migration.checksum,
|
|
105
|
+
)
|
|
106
|
+
finally:
|
|
107
|
+
await connection.execute("SELECT pg_advisory_unlock($1)", MIGRATION_LOCK_ID)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def setup_chainlit_schema(
|
|
111
|
+
database_url: str,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Initialize or migrate Chainlit's PostgreSQL schema."""
|
|
114
|
+
connection = await asyncpg.connect(database_url)
|
|
115
|
+
try:
|
|
116
|
+
await apply_migrations(connection)
|
|
117
|
+
finally:
|
|
118
|
+
await connection.close()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def main() -> None:
|
|
122
|
+
"""Apply pending migrations using the native ``DATABASE_URL`` setting."""
|
|
123
|
+
database_url = os.environ.get("DATABASE_URL")
|
|
124
|
+
if not database_url:
|
|
125
|
+
raise SystemExit("DATABASE_URL must be configured.")
|
|
126
|
+
logging.basicConfig(level=logging.INFO)
|
|
127
|
+
logger.info("Initializing PostgreSQL Chainlit persistence schema")
|
|
128
|
+
asyncio.run(setup_chainlit_schema(database_url))
|
|
129
|
+
logger.info("PostgreSQL Chainlit persistence schema is ready")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _quoted_identifier(value: str) -> str:
|
|
133
|
+
if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", value) is None:
|
|
134
|
+
raise ValueError(f"Invalid PostgreSQL migration table name: {value!r}.")
|
|
135
|
+
return f'"{value}"'
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__":
|
|
139
|
+
main()
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
-- Based on the official Chainlit PostgreSQL data-layer migration at commit
|
|
2
|
+
-- 8844b1b318112961f8589b0614474a85530995df.
|
|
3
|
+
|
|
4
|
+
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
|
5
|
+
|
|
6
|
+
CREATE TYPE "StepType" AS ENUM (
|
|
7
|
+
'assistant_message',
|
|
8
|
+
'embedding',
|
|
9
|
+
'llm',
|
|
10
|
+
'retrieval',
|
|
11
|
+
'rerank',
|
|
12
|
+
'run',
|
|
13
|
+
'system_message',
|
|
14
|
+
'tool',
|
|
15
|
+
'undefined',
|
|
16
|
+
'user_message'
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
CREATE TABLE "Element" (
|
|
20
|
+
"id" TEXT NOT NULL DEFAULT gen_random_uuid(),
|
|
21
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
22
|
+
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
23
|
+
"threadId" TEXT,
|
|
24
|
+
"stepId" TEXT NOT NULL,
|
|
25
|
+
"metadata" JSONB NOT NULL,
|
|
26
|
+
"mime" TEXT,
|
|
27
|
+
"name" TEXT NOT NULL,
|
|
28
|
+
"objectKey" TEXT,
|
|
29
|
+
"url" TEXT,
|
|
30
|
+
"chainlitKey" TEXT,
|
|
31
|
+
"display" TEXT,
|
|
32
|
+
"size" TEXT,
|
|
33
|
+
"language" TEXT,
|
|
34
|
+
"page" INTEGER,
|
|
35
|
+
"props" JSONB,
|
|
36
|
+
CONSTRAINT "Element_pkey" PRIMARY KEY ("id")
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
CREATE TABLE "User" (
|
|
40
|
+
"id" TEXT NOT NULL DEFAULT gen_random_uuid(),
|
|
41
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
42
|
+
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
43
|
+
"metadata" JSONB NOT NULL,
|
|
44
|
+
"identifier" TEXT NOT NULL,
|
|
45
|
+
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
CREATE TABLE "Feedback" (
|
|
49
|
+
"id" TEXT NOT NULL DEFAULT gen_random_uuid(),
|
|
50
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
51
|
+
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
52
|
+
"stepId" TEXT,
|
|
53
|
+
"name" TEXT NOT NULL,
|
|
54
|
+
"value" DOUBLE PRECISION NOT NULL,
|
|
55
|
+
"comment" TEXT,
|
|
56
|
+
CONSTRAINT "Feedback_pkey" PRIMARY KEY ("id")
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
CREATE TABLE "Step" (
|
|
60
|
+
"id" TEXT NOT NULL DEFAULT gen_random_uuid(),
|
|
61
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
62
|
+
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
63
|
+
"parentId" TEXT,
|
|
64
|
+
"threadId" TEXT,
|
|
65
|
+
"input" TEXT,
|
|
66
|
+
"metadata" JSONB NOT NULL,
|
|
67
|
+
"name" TEXT,
|
|
68
|
+
"output" TEXT,
|
|
69
|
+
"type" "StepType" NOT NULL,
|
|
70
|
+
"showInput" TEXT DEFAULT 'json',
|
|
71
|
+
"isError" BOOLEAN DEFAULT false,
|
|
72
|
+
"startTime" TIMESTAMP(3) NOT NULL,
|
|
73
|
+
"endTime" TIMESTAMP(3) NOT NULL,
|
|
74
|
+
CONSTRAINT "Step_pkey" PRIMARY KEY ("id")
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
CREATE TABLE "Thread" (
|
|
78
|
+
"id" TEXT NOT NULL DEFAULT gen_random_uuid(),
|
|
79
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
80
|
+
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
81
|
+
"deletedAt" TIMESTAMP(3),
|
|
82
|
+
"name" TEXT,
|
|
83
|
+
"metadata" JSONB NOT NULL,
|
|
84
|
+
"userId" TEXT,
|
|
85
|
+
CONSTRAINT "Thread_pkey" PRIMARY KEY ("id")
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
CREATE INDEX "Element_stepId_idx" ON "Element"("stepId");
|
|
89
|
+
CREATE INDEX "Element_threadId_idx" ON "Element"("threadId");
|
|
90
|
+
CREATE INDEX "User_identifier_idx" ON "User"("identifier");
|
|
91
|
+
CREATE UNIQUE INDEX "User_identifier_key" ON "User"("identifier");
|
|
92
|
+
CREATE INDEX "Feedback_createdAt_idx" ON "Feedback"("createdAt");
|
|
93
|
+
CREATE INDEX "Feedback_name_idx" ON "Feedback"("name");
|
|
94
|
+
CREATE INDEX "Feedback_stepId_idx" ON "Feedback"("stepId");
|
|
95
|
+
CREATE INDEX "Feedback_value_idx" ON "Feedback"("value");
|
|
96
|
+
CREATE INDEX "Feedback_name_value_idx" ON "Feedback"("name", "value");
|
|
97
|
+
CREATE INDEX "Step_createdAt_idx" ON "Step"("createdAt");
|
|
98
|
+
CREATE INDEX "Step_endTime_idx" ON "Step"("endTime");
|
|
99
|
+
CREATE INDEX "Step_parentId_idx" ON "Step"("parentId");
|
|
100
|
+
CREATE INDEX "Step_startTime_idx" ON "Step"("startTime");
|
|
101
|
+
CREATE INDEX "Step_threadId_idx" ON "Step"("threadId");
|
|
102
|
+
CREATE INDEX "Step_type_idx" ON "Step"("type");
|
|
103
|
+
CREATE INDEX "Step_name_idx" ON "Step"("name");
|
|
104
|
+
CREATE INDEX "Step_threadId_startTime_endTime_idx"
|
|
105
|
+
ON "Step"("threadId", "startTime", "endTime");
|
|
106
|
+
CREATE INDEX "Thread_createdAt_idx" ON "Thread"("createdAt");
|
|
107
|
+
CREATE INDEX "Thread_name_idx" ON "Thread"("name");
|
|
108
|
+
|
|
109
|
+
ALTER TABLE "Element"
|
|
110
|
+
ADD CONSTRAINT "Element_stepId_fkey"
|
|
111
|
+
FOREIGN KEY ("stepId") REFERENCES "Step"("id")
|
|
112
|
+
ON DELETE CASCADE ON UPDATE CASCADE;
|
|
113
|
+
ALTER TABLE "Element"
|
|
114
|
+
ADD CONSTRAINT "Element_threadId_fkey"
|
|
115
|
+
FOREIGN KEY ("threadId") REFERENCES "Thread"("id")
|
|
116
|
+
ON DELETE CASCADE ON UPDATE CASCADE;
|
|
117
|
+
ALTER TABLE "Feedback"
|
|
118
|
+
ADD CONSTRAINT "Feedback_stepId_fkey"
|
|
119
|
+
FOREIGN KEY ("stepId") REFERENCES "Step"("id")
|
|
120
|
+
ON DELETE SET NULL ON UPDATE CASCADE;
|
|
121
|
+
ALTER TABLE "Step"
|
|
122
|
+
ADD CONSTRAINT "Step_parentId_fkey"
|
|
123
|
+
FOREIGN KEY ("parentId") REFERENCES "Step"("id")
|
|
124
|
+
ON DELETE CASCADE ON UPDATE CASCADE;
|
|
125
|
+
ALTER TABLE "Step"
|
|
126
|
+
ADD CONSTRAINT "Step_threadId_fkey"
|
|
127
|
+
FOREIGN KEY ("threadId") REFERENCES "Thread"("id")
|
|
128
|
+
ON DELETE CASCADE ON UPDATE CASCADE;
|
|
129
|
+
ALTER TABLE "Thread"
|
|
130
|
+
ADD CONSTRAINT "Thread_userId_fkey"
|
|
131
|
+
FOREIGN KEY ("userId") REFERENCES "User"("id")
|
|
132
|
+
ON DELETE SET NULL ON UPDATE CASCADE;
|
chainlit_utils/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Configuration for Chainlit utilities."""
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Settings(BaseSettings):
|
|
8
|
+
"""Configuration shared by the reusable Chainlit helpers."""
|
|
9
|
+
|
|
10
|
+
model_config = SettingsConfigDict(
|
|
11
|
+
env_file=".env",
|
|
12
|
+
env_prefix="CHAINLIT_UTILS_",
|
|
13
|
+
env_file_encoding="utf-8",
|
|
14
|
+
extra="ignore",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
MIGRATIONS_TABLE: str = Field(
|
|
18
|
+
default="_chainlit_utils_schema_migrations",
|
|
19
|
+
description="PostgreSQL table that records applied Chainlit migrations.",
|
|
20
|
+
)
|
|
21
|
+
MODEL_CONTEXT_EXCLUDED_KEY: str = Field(
|
|
22
|
+
default="chainlit_utils.exclude_from_model_context",
|
|
23
|
+
description=(
|
|
24
|
+
"Persisted message metadata key that excludes UI-only messages from "
|
|
25
|
+
"model context."
|
|
26
|
+
),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
settings = Settings()
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chainlit-utils
|
|
3
|
+
Version: 0.0.3
|
|
4
|
+
Summary: Reusable persistence and chat helpers for Chainlit applications
|
|
5
|
+
Author: İlker SIĞIRCI
|
|
6
|
+
Author-email: İlker SIĞIRCI <sigirci.ilker@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: asyncpg>=0.30.0
|
|
10
|
+
Requires-Dist: chainlit>=2.11.1
|
|
11
|
+
Requires-Dist: openai>=2.0.0
|
|
12
|
+
Requires-Dist: pydantic-settings>=2.9.0
|
|
13
|
+
Requires-Python: >=3.11, <3.15
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# chainlit-utils
|
|
17
|
+
|
|
18
|
+
Small, reusable helpers for Chainlit applications:
|
|
19
|
+
|
|
20
|
+
- versioned PostgreSQL migrations for Chainlit's official data layer;
|
|
21
|
+
- exclusion of UI-only and failed messages from model context;
|
|
22
|
+
- conversion of simple JSON Schema settings into Chainlit widgets;
|
|
23
|
+
- compact serialization of changed chat settings; and
|
|
24
|
+
- retrieval of the authenticated Chainlit user identifier.
|
|
25
|
+
|
|
26
|
+
The package deliberately does not own model-provider protocols, application
|
|
27
|
+
settings, login callbacks, or completion clients. Its own settings use the
|
|
28
|
+
`CHAINLIT_UTILS_` environment-variable prefix.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
uv add chainlit-utils
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
For local development before publishing:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
uv add --editable /path/to/chainlit-utils
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## PostgreSQL persistence
|
|
43
|
+
|
|
44
|
+
Chainlit uses `DATABASE_URL` to enable its native PostgreSQL data layer. Apply
|
|
45
|
+
the matching schema before starting the application:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
uv run --env-file .env chainlit-utils-migrate
|
|
49
|
+
uv run --env-file .env chainlit run app.py
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Migrations are checksum-protected and serialized with a PostgreSQL advisory
|
|
53
|
+
lock. Existing applications can keep their current migration history table:
|
|
54
|
+
|
|
55
|
+
```dotenv
|
|
56
|
+
CHAINLIT_UTILS_MIGRATIONS_TABLE=_my_app_chainlit_schema_migrations
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Review Chainlit's migration guidance before widening the supported Chainlit
|
|
60
|
+
version range. The bundled migrations target Chainlit 2.11.1 or newer within
|
|
61
|
+
the 2.x series.
|
|
62
|
+
|
|
63
|
+
## Chat helpers
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
import chainlit as cl
|
|
67
|
+
|
|
68
|
+
from chainlit_utils.chat import (
|
|
69
|
+
mark_persisted_errors_excluded,
|
|
70
|
+
send_ui_message,
|
|
71
|
+
text_only_chat_messages,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@cl.on_chat_resume
|
|
76
|
+
async def on_chat_resume(thread):
|
|
77
|
+
mark_persisted_errors_excluded(thread)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@cl.on_message
|
|
81
|
+
async def on_message(_message):
|
|
82
|
+
messages = text_only_chat_messages()
|
|
83
|
+
# Send messages to an OpenAI-compatible client.
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def report_error(error: Exception):
|
|
87
|
+
await send_ui_message(f"Chat completion failed: {error}")
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`text_only_chat_messages` uses Chainlit's native role/content projection. It is
|
|
91
|
+
not a lossless tool-call ledger.
|
|
92
|
+
|
|
93
|
+
UI-only messages use the
|
|
94
|
+
`chainlit_utils.exclude_from_model_context` metadata key by default. Override it
|
|
95
|
+
for an existing application without changing call sites:
|
|
96
|
+
|
|
97
|
+
```dotenv
|
|
98
|
+
CHAINLIT_UTILS_MODEL_CONTEXT_EXCLUDED_KEY=my_app.exclude_from_model_context
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Chat settings
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from chainlit_utils.chat_settings import settings_widgets, serialize_settings
|
|
105
|
+
|
|
106
|
+
widgets = settings_widgets(json_schema, defaults, saved_values)
|
|
107
|
+
await cl.ChatSettings(widgets).send()
|
|
108
|
+
|
|
109
|
+
encoded = serialize_settings(defaults, selected_values, max_length=512)
|
|
110
|
+
metadata = {"my_runtime_settings": encoded} if encoded is not None else {}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The widget adapter intentionally supports only booleans, string enums, and
|
|
114
|
+
strings. The receiving application remains responsible for full schema
|
|
115
|
+
validation.
|
|
116
|
+
|
|
117
|
+
## Development
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
uv sync
|
|
121
|
+
uv run pytest
|
|
122
|
+
uv run ruff check src tests
|
|
123
|
+
uv run ruff format --check src tests
|
|
124
|
+
uv build
|
|
125
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
chainlit_utils/__init__.py,sha256=gYa2cUDDsPNpOBULLNe4swprv75HBIs0fv6vBdisd_s,50
|
|
2
|
+
chainlit_utils/auth.py,sha256=bhaLiJMRI8nztQrbtladdgXqDNweUOfUeLpbTkSh45I,447
|
|
3
|
+
chainlit_utils/chat.py,sha256=dRL9nWPRbegRzV2aMywiL-dVrFpTxWeXIzzhZcKLKKk,2084
|
|
4
|
+
chainlit_utils/chat_settings.py,sha256=6B30_y0fNm_gZidVV0KemRbzF_EMxObaXOuPtArS5_w,4014
|
|
5
|
+
chainlit_utils/database.py,sha256=V4OjTqkfDMyVmf8uQbduYmIDd28xzgDbAMqZT8rjtqI,4776
|
|
6
|
+
chainlit_utils/migrations/0001_chainlit_data_layer.sql,sha256=v_KTwWGsXrd4fa6e39rKL6S9hshJ65Cbajsa5gvbdug,4590
|
|
7
|
+
chainlit_utils/migrations/0002_thread_tags.sql,sha256=g8z073L4nR2ls3uCyekfqbRX-onVP9yxiGcOzJfZTVw,153
|
|
8
|
+
chainlit_utils/migrations/0003_chainlit_2_1_0_command.sql,sha256=94WMN_OL3kHwdwrDH506fM5_JEEBimGqCXHtXGSEVcE,176
|
|
9
|
+
chainlit_utils/migrations/0004_chainlit_2_3_0_default_open.sql,sha256=czf4vbqynSRQEphLnj0Q4FjLfEUrjq2AfPu-1IAUhVc,183
|
|
10
|
+
chainlit_utils/migrations/0005_chainlit_2_9_4_modes.sql,sha256=lHSm-mWBrq4Lyg18Ji3ACI0TMrXfBhGPwfKJqujRazk,175
|
|
11
|
+
chainlit_utils/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
12
|
+
chainlit_utils/settings.py,sha256=_2y0QCy0OD_9FaMulgx_rwNjlsJITwv7tyYiCK6lAM0,857
|
|
13
|
+
chainlit_utils-0.0.3.dist-info/licenses/LICENSE,sha256=uciUysWaaLMcgVggvykngBytlTTwSRTJbYARZYfWcQU,1074
|
|
14
|
+
chainlit_utils-0.0.3.dist-info/WHEEL,sha256=q9Wah2_CgP24mRNSlTyb0F8Yrbz55SzVl8eHLkZ_wvE,80
|
|
15
|
+
chainlit_utils-0.0.3.dist-info/entry_points.txt,sha256=E_G-aGzXSoosTBswSx-5XI5RqqGSbbSgdvEMMFXdsOY,73
|
|
16
|
+
chainlit_utils-0.0.3.dist-info/METADATA,sha256=Cfbuk3MIPLuSMLbvwxI37PiV0uxlFJ0mQNeEMi6Xcvk,3392
|
|
17
|
+
chainlit_utils-0.0.3.dist-info/RECORD,,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, İlker SIĞIRCI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|