millwork 0.1.0__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.
Files changed (70) hide show
  1. millwork/__init__.py +45 -0
  2. millwork/_bundle/dist/.vite/manifest.json +92 -0
  3. millwork/_bundle/dist/charts-inner-Cq0vWbjt.js +39 -0
  4. millwork/_bundle/dist/favicon.svg +15 -0
  5. millwork/_bundle/dist/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  6. millwork/_bundle/dist/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  7. millwork/_bundle/dist/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  8. millwork/_bundle/dist/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  9. millwork/_bundle/dist/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  10. millwork/_bundle/dist/icons.svg +24 -0
  11. millwork/_bundle/dist/index.global-cOFus1M1.js +952 -0
  12. millwork/_bundle/dist/index.modern-CITEfQrV.js +135 -0
  13. millwork/_bundle/dist/lib-3DI92Zxk.js +1 -0
  14. millwork/_bundle/dist/main-BoZpFGOH.js +64 -0
  15. millwork/_bundle/dist/main-F2tEXmC5.css +2 -0
  16. millwork/_bundle/dist/react-markdown-DLcbNoI3.js +14 -0
  17. millwork/_bundle/dist/remark-gfm-DoHjwpPg.js +16 -0
  18. millwork/app.py +726 -0
  19. millwork/auth.py +877 -0
  20. millwork/chat.py +397 -0
  21. millwork/cli.py +316 -0
  22. millwork/context.py +42 -0
  23. millwork/files.py +450 -0
  24. millwork/forms.py +349 -0
  25. millwork/manual/__init__.py +449 -0
  26. millwork/manual/components/Hello.jsx +24 -0
  27. millwork/manual/components/Voice.jsx +46 -0
  28. millwork/manual/pages/app/auth.md +188 -0
  29. millwork/manual/pages/app/pages.md +200 -0
  30. millwork/manual/pages/app/running.md +242 -0
  31. millwork/manual/pages/app/state.md +151 -0
  32. millwork/manual/pages/app/testing.md +147 -0
  33. millwork/manual/pages/app/theming.md +122 -0
  34. millwork/manual/pages/components/charts.md +263 -0
  35. millwork/manual/pages/components/chat.md +280 -0
  36. millwork/manual/pages/components/custom.md +148 -0
  37. millwork/manual/pages/components/data.md +112 -0
  38. millwork/manual/pages/components/feedback.md +212 -0
  39. millwork/manual/pages/components/files.md +194 -0
  40. millwork/manual/pages/components/form.md +135 -0
  41. millwork/manual/pages/components/inputs.md +360 -0
  42. millwork/manual/pages/components/layout.md +154 -0
  43. millwork/manual/pages/components/tasks.md +140 -0
  44. millwork/manual/pages/components/text.md +145 -0
  45. millwork/manual/pages/index.md +67 -0
  46. millwork/manual/public/fonts/InterVariable.woff2 +0 -0
  47. millwork/manual/public/fonts/LICENSE.txt +2 -0
  48. millwork/manual/public/mark.svg +15 -0
  49. millwork/manual/public/theme.json +97 -0
  50. millwork/nodes.py +300 -0
  51. millwork/protocol.py +238 -0
  52. millwork/proxy.py +122 -0
  53. millwork/py.typed +0 -0
  54. millwork/session.py +1422 -0
  55. millwork/shell.py +226 -0
  56. millwork/tasks.py +473 -0
  57. millwork/testing.py +336 -0
  58. millwork/theme.py +338 -0
  59. millwork/ui/__init__.py +725 -0
  60. millwork/ui/charts.py +214 -0
  61. millwork/ui/chat.py +97 -0
  62. millwork/ui/files.py +101 -0
  63. millwork/ui/form.py +102 -0
  64. millwork/ui/table.py +145 -0
  65. millwork/ws.py +220 -0
  66. millwork-0.1.0.dist-info/METADATA +131 -0
  67. millwork-0.1.0.dist-info/RECORD +70 -0
  68. millwork-0.1.0.dist-info/WHEEL +4 -0
  69. millwork-0.1.0.dist-info/entry_points.txt +2 -0
  70. millwork-0.1.0.dist-info/licenses/LICENSE +202 -0
millwork/chat.py ADDED
@@ -0,0 +1,397 @@
1
+ """Chat: messages the session owns, a turn, and the reply handle that streams.
2
+
3
+ On the wire a reply is the AI SDK UI message stream: ``text-start``,
4
+ ``text-delta``, ``tool-input-available`` and so on, one chunk per
5
+ ``Stream`` message addressed to the Chat node. The bundle parses that into
6
+ message parts and renders them. Between turns the finished messages travel
7
+ as a prop; the in-flight message is the stream's alone.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import contextlib
14
+ import logging
15
+ import secrets
16
+ import time
17
+ from collections.abc import AsyncIterator, Sequence
18
+ from typing import TYPE_CHECKING, Any, Literal
19
+
20
+ from pydantic import BaseModel, ConfigDict, Field
21
+
22
+ from millwork.files import FileRef
23
+
24
+ if TYPE_CHECKING:
25
+ from millwork.nodes import Component
26
+ from millwork.session import Session
27
+
28
+ log = logging.getLogger("millwork.chat")
29
+
30
+ Role = Literal["user", "assistant", "system"]
31
+
32
+
33
+ class UIMessage(BaseModel):
34
+ """The AI SDK message shape: a role and a list of parts."""
35
+
36
+ model_config = ConfigDict(extra="allow")
37
+
38
+ id: str
39
+ role: Role
40
+ parts: list[dict[str, Any]] = Field(default_factory=list)
41
+ metadata: dict[str, Any] | None = None
42
+
43
+ @property
44
+ def text(self) -> str:
45
+ return "".join(p.get("text", "") for p in self.parts if p.get("type") == "text")
46
+
47
+
48
+ class Thread:
49
+ """One Chat node's messages, kept by the session under the node's key."""
50
+
51
+ def __init__(self) -> None:
52
+ self.messages: list[UIMessage] = []
53
+ self.inflight: UIMessage | None = None
54
+ self.turn_task: asyncio.Task[Any] | None = None
55
+ self.inline: dict[str, Component] = {}
56
+ self.suggestions: list[str] = []
57
+ # The most messages the session keeps for this thread; the oldest go.
58
+ # The Chat's ``max_messages`` sets it, so a thread never outgrows what
59
+ # the app asked for, whatever a visitor sends.
60
+ self.limit = 200
61
+
62
+ @property
63
+ def busy(self) -> bool:
64
+ """From the moment a turn's handler starts until it ends, not only while it streams."""
65
+ return self.inflight is not None or self.turn_task is not None
66
+
67
+ def clear(self) -> None:
68
+ self.messages.clear()
69
+ self.inline.clear()
70
+ self.suggestions.clear()
71
+
72
+ def load(self, messages: Sequence[UIMessage | dict[str, Any]]) -> None:
73
+ self.messages = [m if isinstance(m, UIMessage) else UIMessage(**m) for m in messages]
74
+ self._trim()
75
+
76
+ def append(self, message: UIMessage) -> None:
77
+ self.messages.append(message)
78
+ self._trim()
79
+
80
+ def _trim(self) -> None:
81
+ extra = len(self.messages) - self.limit
82
+ if extra > 0:
83
+ del self.messages[:extra]
84
+ self._sweep_inline()
85
+
86
+ def find(self, message_id: str) -> int | None:
87
+ return next((i for i, m in enumerate(self.messages) if m.id == message_id), None)
88
+
89
+ def truncate(self, index: int) -> None:
90
+ """Drop the messages from ``index`` on, and the inline components only they used."""
91
+ del self.messages[index:]
92
+ self._sweep_inline()
93
+ self.suggestions.clear()
94
+
95
+ def _sweep_inline(self) -> None:
96
+ """Drop the inline components no remaining message uses."""
97
+ kept = {
98
+ p.get("data", {}).get("slot")
99
+ for m in self.messages
100
+ for p in m.parts
101
+ if p.get("type") == "data-component"
102
+ }
103
+ for slot in [s for s in self.inline if s not in kept]:
104
+ del self.inline[slot]
105
+
106
+
107
+ class ChatTurn:
108
+ """What ``on_message`` receives: the user's message and a way to reply."""
109
+
110
+ def __init__(
111
+ self,
112
+ session: Session[Any],
113
+ node_id: str,
114
+ thread: Thread,
115
+ message: UIMessage,
116
+ files: list[FileRef],
117
+ command: str | None,
118
+ ) -> None:
119
+ self.session = session
120
+ self.node_id = node_id
121
+ self.thread = thread
122
+ self.message = message
123
+ self.files = files
124
+ self.command = command
125
+
126
+ @property
127
+ def text(self) -> str:
128
+ return self.message.text
129
+
130
+ @property
131
+ def history(self) -> list[UIMessage]:
132
+ """Every message before this turn's, oldest first."""
133
+ return [m for m in self.thread.messages if m.id != self.message.id]
134
+
135
+ @contextlib.asynccontextmanager
136
+ async def reply(self) -> AsyncIterator[Reply]:
137
+ reply = Reply(self.session, self.node_id, self.thread)
138
+ await reply._start()
139
+ try:
140
+ yield reply
141
+ except asyncio.CancelledError:
142
+ await reply._finish("stopped")
143
+ raise
144
+ except Exception as exc:
145
+ log.exception("chat turn failed")
146
+ await reply.error(str(exc) or type(exc).__name__)
147
+ await reply._finish("error")
148
+ raise
149
+ else:
150
+ await reply._finish("stop")
151
+
152
+
153
+ class Reply:
154
+ """Streams one assistant message. Each call is a chunk on the wire and a part in the message."""
155
+
156
+ def __init__(self, session: Session[Any], node_id: str, thread: Thread) -> None:
157
+ self._s = session
158
+ self._node = node_id
159
+ self._thread = thread
160
+ # When the reply began and how long it took, kept in the message's metadata
161
+ # so the time shows on a reload too; the tab reads them when asked to.
162
+ self.message = UIMessage(
163
+ id=secrets.token_urlsafe(8), role="assistant", metadata={"at": time.time()}
164
+ )
165
+ self._text_id: str | None = None
166
+ self._finished = False
167
+ self._started = time.monotonic()
168
+ self._tool_started: dict[str, float] = {}
169
+
170
+ async def _send(self, chunk: dict[str, Any]) -> None:
171
+ await self._s.stream(self._node, chunk)
172
+
173
+ async def _start(self) -> None:
174
+ self._thread.inflight = self.message
175
+ self._thread.suggestions = []
176
+ self._s.refresh()
177
+ start = {"type": "start", "messageId": self.message.id}
178
+ await self._send({**start, "messageMetadata": self.message.metadata})
179
+ await self._send({"type": "start-step"})
180
+
181
+ async def _finish(self, reason: str) -> None:
182
+ if self._finished:
183
+ return
184
+ self._finished = True
185
+ await self._end_text()
186
+ took = round(time.monotonic() - self._started, 3)
187
+ self.message.metadata = {**(self.message.metadata or {}), "took": took}
188
+ await self._send({"type": "message-metadata", "messageMetadata": {"took": took}})
189
+ await self._send({"type": "finish-step"})
190
+ await self._send({"type": "finish", "finishReason": reason})
191
+ self._thread.append(self.message)
192
+ self._thread.inflight = None
193
+ self._s.refresh()
194
+
195
+ # ------------------------------------------------------------- parts
196
+
197
+ async def _end_text(self) -> None:
198
+ if self._text_id is not None:
199
+ await self._send({"type": "text-end", "id": self._text_id})
200
+ self._text_id = None
201
+
202
+ async def text(self, chunk: str) -> None:
203
+ """Append text to the reply; consecutive calls extend one text part."""
204
+ if not chunk:
205
+ return
206
+ if self._text_id is None:
207
+ self._text_id = secrets.token_urlsafe(6)
208
+ self.message.parts.append({"type": "text", "text": "", "state": "streaming"})
209
+ await self._send({"type": "text-start", "id": self._text_id})
210
+ self.message.parts[-1]["text"] += chunk
211
+ await self._send({"type": "text-delta", "id": self._text_id, "delta": chunk})
212
+
213
+ @contextlib.asynccontextmanager
214
+ async def reasoning(self) -> AsyncIterator[ReasoningHandle]:
215
+ await self._end_text()
216
+ rid = secrets.token_urlsafe(6)
217
+ part: dict[str, Any] = {"type": "reasoning", "text": "", "state": "streaming"}
218
+ self.message.parts.append(part)
219
+ await self._send({"type": "reasoning-start", "id": rid})
220
+ try:
221
+ yield ReasoningHandle(self, rid, part)
222
+ finally:
223
+ part["state"] = "done"
224
+ await self._send({"type": "reasoning-end", "id": rid})
225
+
226
+ async def tool(self, name: str, input: Any = None) -> ToolHandle:
227
+ """A tool call: its input now, its output or error through the handle."""
228
+ await self._end_text()
229
+ call_id = secrets.token_urlsafe(6)
230
+ part: dict[str, Any] = {
231
+ "type": f"tool-{name}",
232
+ "toolCallId": call_id,
233
+ "state": "input-available",
234
+ "input": input,
235
+ }
236
+ self.message.parts.append(part)
237
+ self._tool_started[call_id] = time.monotonic()
238
+ await self._send(
239
+ {
240
+ "type": "tool-input-available",
241
+ "toolCallId": call_id,
242
+ "toolName": name,
243
+ "input": input,
244
+ }
245
+ )
246
+ return ToolHandle(self, call_id, part)
247
+
248
+ def _took(self, call_id: str) -> float:
249
+ started = self._tool_started.pop(call_id, None)
250
+ return round(time.monotonic() - started, 3) if started is not None else 0.0
251
+
252
+ @contextlib.asynccontextmanager
253
+ async def step(self, label: str) -> AsyncIterator[ToolHandle]:
254
+ """A labelled step in the transcript: a tool call without a tool."""
255
+ handle = await self.tool("step", {"label": label})
256
+ try:
257
+ yield handle
258
+ except Exception as exc:
259
+ await handle.error(str(exc) or type(exc).__name__)
260
+ raise
261
+ else:
262
+ if handle.part["state"] == "input-available":
263
+ await handle.output(None)
264
+
265
+ async def source(
266
+ self, title: str, url: str | None = None, snippet: str | None = None, **meta: Any
267
+ ) -> None:
268
+ await self._end_text()
269
+ sid = secrets.token_urlsafe(6)
270
+ if url:
271
+ part = {
272
+ "type": "source-url",
273
+ "sourceId": sid,
274
+ "url": url,
275
+ "title": title,
276
+ **({"snippet": snippet} if snippet else {}),
277
+ **meta,
278
+ }
279
+ self.message.parts.append(part)
280
+ await self._send(part)
281
+ else:
282
+ part = {
283
+ "type": "source-document",
284
+ "sourceId": sid,
285
+ "mediaType": meta.pop("media_type", "text/plain"),
286
+ "title": title,
287
+ **({"snippet": snippet} if snippet else {}),
288
+ **meta,
289
+ }
290
+ self.message.parts.append(part)
291
+ await self._send(part)
292
+
293
+ async def file(
294
+ self, content: FileRef | bytes | str, name: str | None = None, mime: str | None = None
295
+ ) -> FileRef:
296
+ """A file in the reply, stored on the session; the tab fetches it by id."""
297
+ from millwork.files import content_to_file
298
+
299
+ ref = (
300
+ content
301
+ if isinstance(content, FileRef)
302
+ else content_to_file(self._s, content, name or "file", mime)
303
+ )
304
+ await self._end_text()
305
+ part = {
306
+ "type": "file",
307
+ "mediaType": ref.mime,
308
+ "url": "",
309
+ "filename": ref.name,
310
+ "fileId": ref.id,
311
+ }
312
+ self.message.parts.append(part)
313
+ await self._send(part)
314
+ return ref
315
+
316
+ async def image(
317
+ self, content: FileRef | bytes, name: str = "image.png", mime: str = "image/png"
318
+ ) -> FileRef:
319
+ return await self.file(content, name, mime)
320
+
321
+ async def status(self, text: str) -> None:
322
+ """A transient marker under the message while it streams."""
323
+ await self._send({"type": "data-status", "data": {"text": text}, "transient": True})
324
+
325
+ async def suggestions(self, items: list[str]) -> None:
326
+ self._thread.suggestions = list(items)
327
+ await self._send(
328
+ {"type": "data-suggestions", "data": {"items": list(items)}, "transient": True}
329
+ )
330
+
331
+ async def data(self, kind: str, payload: Any, *, id: str | None = None) -> None:
332
+ """A custom part rendered by the app's ``components/<kind>.jsx``."""
333
+ await self._end_text()
334
+ part: dict[str, Any] = {"type": f"data-{kind}", "data": payload}
335
+ if id:
336
+ part["id"] = id
337
+ for i, existing in enumerate(self.message.parts):
338
+ if existing.get("type") == part["type"] and existing.get("id") == id:
339
+ self.message.parts[i] = part
340
+ break
341
+ else:
342
+ self.message.parts.append(part)
343
+ else:
344
+ self.message.parts.append(part)
345
+ await self._send(part)
346
+
347
+ async def component(self, component: Component) -> str:
348
+ """A catalogue (or custom) component inside the message, patched like any node."""
349
+ await self._end_text()
350
+ part_id = secrets.token_urlsafe(6)
351
+ self._thread.inline[part_id] = component
352
+ part = {"type": "data-component", "id": part_id, "data": {"slot": part_id}}
353
+ self.message.parts.append(part)
354
+ self._s.refresh()
355
+ await self._send(part)
356
+ return part_id
357
+
358
+ async def error(self, text: str) -> None:
359
+ await self._send({"type": "error", "errorText": text})
360
+
361
+
362
+ class ReasoningHandle:
363
+ def __init__(self, reply: Reply, rid: str, part: dict[str, Any]) -> None:
364
+ self._reply = reply
365
+ self._id = rid
366
+ self._part = part
367
+
368
+ async def text(self, chunk: str) -> None:
369
+ if not chunk:
370
+ return
371
+ self._part["text"] += chunk
372
+ await self._reply._send({"type": "reasoning-delta", "id": self._id, "delta": chunk})
373
+
374
+
375
+ class ToolHandle:
376
+ def __init__(self, reply: Reply, call_id: str, part: dict[str, Any]) -> None:
377
+ self._reply = reply
378
+ self.id = call_id
379
+ self.part = part
380
+
381
+ async def output(self, result: Any) -> None:
382
+ took = self._reply._took(self.id)
383
+ self.part["state"] = "output-available"
384
+ self.part["output"] = result
385
+ self.part["took"] = took
386
+ await self._reply._send(
387
+ {"type": "tool-output-available", "toolCallId": self.id, "output": result, "took": took}
388
+ )
389
+
390
+ async def error(self, text: str) -> None:
391
+ took = self._reply._took(self.id)
392
+ self.part["state"] = "output-error"
393
+ self.part["errorText"] = text
394
+ self.part["took"] = took
395
+ await self._reply._send(
396
+ {"type": "tool-output-error", "toolCallId": self.id, "errorText": text, "took": took}
397
+ )