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.
@@ -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;
@@ -0,0 +1,4 @@
1
+ -- Official Chainlit data-layer migration 20250108095538_add_tags_to_thread.
2
+
3
+ ALTER TABLE "Thread"
4
+ ADD COLUMN "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];
@@ -0,0 +1,5 @@
1
+ -- Required by the official Chainlit 2.1.0 migration guide:
2
+ -- https://docs.chainlit.io/guides/migration/2.1.0
3
+
4
+ ALTER TABLE "Step"
5
+ ADD COLUMN IF NOT EXISTS "command" TEXT;
@@ -0,0 +1,5 @@
1
+ -- Required by the official Chainlit 2.3.0 migration guide:
2
+ -- https://docs.chainlit.io/guides/migration/2.3.0
3
+
4
+ ALTER TABLE "Step"
5
+ ADD COLUMN IF NOT EXISTS "defaultOpen" BOOLEAN;
@@ -0,0 +1,5 @@
1
+ -- Required by the official Chainlit 2.9.4 migration guide:
2
+ -- https://docs.chainlit.io/guides/migration/2.9.4
3
+
4
+ ALTER TABLE "Step"
5
+ ADD COLUMN IF NOT EXISTS "modes" JSONB;
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.2
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ chainlit-utils-migrate = chainlit_utils.database:main
3
+
@@ -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
+