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.
- millwork/__init__.py +45 -0
- millwork/_bundle/dist/.vite/manifest.json +92 -0
- millwork/_bundle/dist/charts-inner-Cq0vWbjt.js +39 -0
- millwork/_bundle/dist/favicon.svg +15 -0
- millwork/_bundle/dist/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- millwork/_bundle/dist/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- millwork/_bundle/dist/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- millwork/_bundle/dist/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- millwork/_bundle/dist/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- millwork/_bundle/dist/icons.svg +24 -0
- millwork/_bundle/dist/index.global-cOFus1M1.js +952 -0
- millwork/_bundle/dist/index.modern-CITEfQrV.js +135 -0
- millwork/_bundle/dist/lib-3DI92Zxk.js +1 -0
- millwork/_bundle/dist/main-BoZpFGOH.js +64 -0
- millwork/_bundle/dist/main-F2tEXmC5.css +2 -0
- millwork/_bundle/dist/react-markdown-DLcbNoI3.js +14 -0
- millwork/_bundle/dist/remark-gfm-DoHjwpPg.js +16 -0
- millwork/app.py +726 -0
- millwork/auth.py +877 -0
- millwork/chat.py +397 -0
- millwork/cli.py +316 -0
- millwork/context.py +42 -0
- millwork/files.py +450 -0
- millwork/forms.py +349 -0
- millwork/manual/__init__.py +449 -0
- millwork/manual/components/Hello.jsx +24 -0
- millwork/manual/components/Voice.jsx +46 -0
- millwork/manual/pages/app/auth.md +188 -0
- millwork/manual/pages/app/pages.md +200 -0
- millwork/manual/pages/app/running.md +242 -0
- millwork/manual/pages/app/state.md +151 -0
- millwork/manual/pages/app/testing.md +147 -0
- millwork/manual/pages/app/theming.md +122 -0
- millwork/manual/pages/components/charts.md +263 -0
- millwork/manual/pages/components/chat.md +280 -0
- millwork/manual/pages/components/custom.md +148 -0
- millwork/manual/pages/components/data.md +112 -0
- millwork/manual/pages/components/feedback.md +212 -0
- millwork/manual/pages/components/files.md +194 -0
- millwork/manual/pages/components/form.md +135 -0
- millwork/manual/pages/components/inputs.md +360 -0
- millwork/manual/pages/components/layout.md +154 -0
- millwork/manual/pages/components/tasks.md +140 -0
- millwork/manual/pages/components/text.md +145 -0
- millwork/manual/pages/index.md +67 -0
- millwork/manual/public/fonts/InterVariable.woff2 +0 -0
- millwork/manual/public/fonts/LICENSE.txt +2 -0
- millwork/manual/public/mark.svg +15 -0
- millwork/manual/public/theme.json +97 -0
- millwork/nodes.py +300 -0
- millwork/protocol.py +238 -0
- millwork/proxy.py +122 -0
- millwork/py.typed +0 -0
- millwork/session.py +1422 -0
- millwork/shell.py +226 -0
- millwork/tasks.py +473 -0
- millwork/testing.py +336 -0
- millwork/theme.py +338 -0
- millwork/ui/__init__.py +725 -0
- millwork/ui/charts.py +214 -0
- millwork/ui/chat.py +97 -0
- millwork/ui/files.py +101 -0
- millwork/ui/form.py +102 -0
- millwork/ui/table.py +145 -0
- millwork/ws.py +220 -0
- millwork-0.1.0.dist-info/METADATA +131 -0
- millwork-0.1.0.dist-info/RECORD +70 -0
- millwork-0.1.0.dist-info/WHEEL +4 -0
- millwork-0.1.0.dist-info/entry_points.txt +2 -0
- millwork-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Chat
|
|
3
|
+
order: 9
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Chat
|
|
7
|
+
|
|
8
|
+
`ui.Chat` is a conversation: messages, a composer with attachments and
|
|
9
|
+
commands, and a streamed reply from Python. It needs a `key`, which names
|
|
10
|
+
the thread the session keeps for it, and an `on_message` handler that gets a
|
|
11
|
+
`mw.ChatTurn` and replies through it. The chat fills whatever height it is
|
|
12
|
+
given, so give it one with `cls` inside a card. `attachments=ui.Accept(...)`
|
|
13
|
+
says what a person may attach, of which types, how many and how large;
|
|
14
|
+
without it the composer takes no files. `max_chars` caps a message and
|
|
15
|
+
`max_messages` the thread, as the section below says.
|
|
16
|
+
`tools=[...]` puts your own buttons in the composer; the section below
|
|
17
|
+
and the Custom chapter show what goes there.
|
|
18
|
+
|
|
19
|
+
## A streamed reply
|
|
20
|
+
|
|
21
|
+
`turn.text`, `turn.files` and `turn.command` are what the user sent. Inside
|
|
22
|
+
`async with turn.reply() as reply`, `reply.text(chunk)` streams words,
|
|
23
|
+
`reply.reasoning()` opens a thinking block, `reply.step(label)` a tool
|
|
24
|
+
step, `reply.status(text)` a transient marker, and `reply.suggestions([...])`
|
|
25
|
+
follow-up chips. The whole turn is a task: the stop button cancels it.
|
|
26
|
+
|
|
27
|
+
```python live
|
|
28
|
+
import asyncio
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def answer(s, turn):
|
|
32
|
+
text = turn.text.strip() or "nothing"
|
|
33
|
+
if turn.command == "shout":
|
|
34
|
+
text = text.upper()
|
|
35
|
+
async with turn.reply() as reply:
|
|
36
|
+
async with reply.reasoning() as r:
|
|
37
|
+
await r.text("Deciding what to say about ")
|
|
38
|
+
await asyncio.sleep(0.3)
|
|
39
|
+
await r.text(f"“{text}”.")
|
|
40
|
+
async with reply.step("Looking it up") as step:
|
|
41
|
+
await asyncio.sleep(0.4)
|
|
42
|
+
await step.output({"hits": 3})
|
|
43
|
+
for word in f"You said {text}, and I heard every word of it.".split(" "):
|
|
44
|
+
await reply.text(word + " ")
|
|
45
|
+
await asyncio.sleep(0.05)
|
|
46
|
+
for f in turn.files:
|
|
47
|
+
await reply.file(f)
|
|
48
|
+
await reply.suggestions(["Say it again", "Try /shout"])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
ui.Card(
|
|
52
|
+
ui.Chat(
|
|
53
|
+
key="demo",
|
|
54
|
+
welcome="Ask anything. The reply streams back from Python.",
|
|
55
|
+
suggestions=["What can you do?", "Write a haiku"],
|
|
56
|
+
placeholder="Type a message",
|
|
57
|
+
attachments=ui.Accept(accept=[".txt", ".md"], max_files=2),
|
|
58
|
+
commands=[
|
|
59
|
+
ui.Command(id="shout", label="Shout", icon="volume-2", description="Reply in capitals")
|
|
60
|
+
],
|
|
61
|
+
on_message=answer,
|
|
62
|
+
cls="h-[28rem]",
|
|
63
|
+
),
|
|
64
|
+
padding=False,
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## The thread
|
|
69
|
+
|
|
70
|
+
`s.chat(key)` is the thread: `messages` holds what was said, `clear()`
|
|
71
|
+
empties it, and `load(messages)` restores a stored conversation. `busy` is
|
|
72
|
+
true while a reply is streaming. The session keeps the last `max_messages`
|
|
73
|
+
of the Chat, 200 by default, each at most `max_chars` long, 16,000 by
|
|
74
|
+
default, so a thread holds a known amount whatever a visitor sends.
|
|
75
|
+
`s.drop_chat(key)` forgets a thread altogether, for an app that keeps its
|
|
76
|
+
threads in a store and only the open one in the session.
|
|
77
|
+
|
|
78
|
+
```python live
|
|
79
|
+
ui.Row(
|
|
80
|
+
ui.Button(
|
|
81
|
+
"Clear the chat above",
|
|
82
|
+
variant="outline",
|
|
83
|
+
icon="trash-2",
|
|
84
|
+
on_click=lambda s, _: s.chat("demo").clear(),
|
|
85
|
+
),
|
|
86
|
+
ui.Button(
|
|
87
|
+
"Count messages",
|
|
88
|
+
variant="outline",
|
|
89
|
+
on_click=lambda s, _: s.toast(f"{len(s.chat('demo').messages)} messages"),
|
|
90
|
+
),
|
|
91
|
+
gap=2,
|
|
92
|
+
)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Parts a reply can carry
|
|
96
|
+
|
|
97
|
+
| Call | Shows as |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| `await reply.text(chunk)` | streamed Markdown |
|
|
100
|
+
| `async with reply.reasoning() as r` | a folded thinking block, `r.text(...)` inside |
|
|
101
|
+
| `async with reply.step(label) as step` | a tool step with `step.output(...)` |
|
|
102
|
+
| `await reply.source(title, url, snippet)` | a source chip |
|
|
103
|
+
| `await reply.file(ref)` and `reply.image(ref)` | an attachment, an inline image |
|
|
104
|
+
| `await reply.status(text)` | a marker that disappears when the reply ends |
|
|
105
|
+
| `await reply.component(component)` | any catalogue component inside the message |
|
|
106
|
+
| `await reply.suggestions([...])` | follow-up chips |
|
|
107
|
+
| `await reply.error(text)` | an error line |
|
|
108
|
+
|
|
109
|
+
## Reading aids
|
|
110
|
+
|
|
111
|
+
Four things are off until asked for, since a chat is often clean enough
|
|
112
|
+
without them: `avatar` puts a picture beside the assistant's messages,
|
|
113
|
+
`names=True` and `timestamps=True` write who said it and when above each
|
|
114
|
+
message (on hover, always on a touch screen; `assistant_name` sets the
|
|
115
|
+
name), and `durations=True` shows how long the reply took and how long
|
|
116
|
+
each step ran. The times are kept on the messages, so a loaded thread
|
|
117
|
+
shows them too.
|
|
118
|
+
|
|
119
|
+
```python live
|
|
120
|
+
import asyncio
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def answer(s, turn):
|
|
124
|
+
async with turn.reply() as reply:
|
|
125
|
+
async with reply.step("Checking the archive") as step:
|
|
126
|
+
await asyncio.sleep(0.6)
|
|
127
|
+
await step.output({"found": 2})
|
|
128
|
+
await reply.text(f"Two notes mention {turn.text.strip() or 'nothing'}.")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
ui.Card(
|
|
132
|
+
ui.Chat(
|
|
133
|
+
key="aids",
|
|
134
|
+
welcome="Hover a message for its name and time.",
|
|
135
|
+
avatar="/public/mark.svg",
|
|
136
|
+
assistant_name="Archivist",
|
|
137
|
+
names=True,
|
|
138
|
+
timestamps=True,
|
|
139
|
+
durations=True,
|
|
140
|
+
on_message=answer,
|
|
141
|
+
cls="h-[20rem]",
|
|
142
|
+
),
|
|
143
|
+
padding=False,
|
|
144
|
+
)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Tools in the composer
|
|
148
|
+
|
|
149
|
+
`tools=[...]` renders components left of Send. A `ui.Button` there is an
|
|
150
|
+
ordinary button with a handler in Python, and nothing says it must touch
|
|
151
|
+
the conversation: this gear opens a `ui.Dialog` that lives next to the
|
|
152
|
+
chat, the dialog changes state, and the next reply reads it. Nothing is
|
|
153
|
+
sent and no message appears. A custom component in the slot gets a `chat`
|
|
154
|
+
prop to upload and send with; the Custom chapter builds a voice note
|
|
155
|
+
button on it.
|
|
156
|
+
|
|
157
|
+
```python live
|
|
158
|
+
from pydantic import BaseModel
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class Settings(BaseModel):
|
|
162
|
+
open: bool = False
|
|
163
|
+
tone: str = "plain"
|
|
164
|
+
sign: bool = False
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
async def answer(s, turn):
|
|
168
|
+
st = state(Settings)
|
|
169
|
+
async with turn.reply() as reply:
|
|
170
|
+
text = turn.text.upper() if st.tone == "shout" else turn.text
|
|
171
|
+
await reply.text(f"You said: {text}" + (" (sent from the manual)" if st.sign else ""))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def example(s):
|
|
175
|
+
st = state(Settings)
|
|
176
|
+
|
|
177
|
+
def opened(value):
|
|
178
|
+
def handler(s, _):
|
|
179
|
+
st.open = value
|
|
180
|
+
|
|
181
|
+
return handler
|
|
182
|
+
|
|
183
|
+
return ui.Stack(
|
|
184
|
+
ui.Card(
|
|
185
|
+
ui.Chat(
|
|
186
|
+
key="settings-demo",
|
|
187
|
+
welcome="The gear in the composer opens the settings.",
|
|
188
|
+
tools=[
|
|
189
|
+
ui.Button(
|
|
190
|
+
key="gear",
|
|
191
|
+
icon="settings",
|
|
192
|
+
variant="ghost",
|
|
193
|
+
size="sm",
|
|
194
|
+
tooltip="Settings",
|
|
195
|
+
on_click=opened(True),
|
|
196
|
+
)
|
|
197
|
+
],
|
|
198
|
+
on_message=answer,
|
|
199
|
+
cls="h-[20rem]",
|
|
200
|
+
),
|
|
201
|
+
padding=False,
|
|
202
|
+
),
|
|
203
|
+
ui.Dialog(
|
|
204
|
+
ui.Field(
|
|
205
|
+
ui.Select(
|
|
206
|
+
key="tone",
|
|
207
|
+
options=["plain", "shout"],
|
|
208
|
+
value=st.tone,
|
|
209
|
+
on_change=lambda s, v: setattr(st, "tone", v or "plain"),
|
|
210
|
+
),
|
|
211
|
+
label="Tone",
|
|
212
|
+
),
|
|
213
|
+
ui.Switch(
|
|
214
|
+
key="sign",
|
|
215
|
+
label="Sign the replies",
|
|
216
|
+
checked=st.sign,
|
|
217
|
+
on_change=lambda s, v: setattr(st, "sign", bool(v)),
|
|
218
|
+
),
|
|
219
|
+
key="settings",
|
|
220
|
+
open=st.open,
|
|
221
|
+
title="Chat settings",
|
|
222
|
+
on_close=opened(False),
|
|
223
|
+
footer=ui.Button("Done", on_click=opened(False)),
|
|
224
|
+
),
|
|
225
|
+
)
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Under a message
|
|
229
|
+
|
|
230
|
+
Hover an answer and a row of buttons appears: copy, thumbs up and down
|
|
231
|
+
when the chat has an `on_feedback` handler, regenerate on the latest
|
|
232
|
+
answer, and any `actions` of the app's own. A thumbs down asks for a
|
|
233
|
+
comment. The choice stays on the message, so it is there after a reload,
|
|
234
|
+
and millwork keeps nothing else: `on_feedback` gets the message, the value
|
|
235
|
+
and the comment, and the app stores them where it wants. A user message
|
|
236
|
+
has an edit button; the edited message is answered again and what followed
|
|
237
|
+
it goes. `regenerate=False`, `edit=False` and `copyable=False` switch each
|
|
238
|
+
off.
|
|
239
|
+
|
|
240
|
+
```python live
|
|
241
|
+
from pydantic import BaseModel
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class Feedback(BaseModel):
|
|
245
|
+
notes: list[str] = []
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
async def echo(s, turn):
|
|
249
|
+
async with turn.reply() as reply:
|
|
250
|
+
await reply.text(f"You said: {turn.text}")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
async def rated(s, message, value, comment):
|
|
254
|
+
what = "cleared" if value is None else value
|
|
255
|
+
note = f"{what}: {message.text[:30]}" + (f" ({comment})" if comment else "")
|
|
256
|
+
state(Feedback).notes.append(note)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
async def act(s, message, action):
|
|
260
|
+
s.toast(f"{action}: {message.text}")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def example(s):
|
|
264
|
+
notes = state(Feedback).notes
|
|
265
|
+
return ui.Stack(
|
|
266
|
+
ui.Card(
|
|
267
|
+
ui.Chat(
|
|
268
|
+
key="rated",
|
|
269
|
+
welcome="Say something, then rate the answer or edit your message.",
|
|
270
|
+
on_message=echo,
|
|
271
|
+
on_feedback=rated,
|
|
272
|
+
on_action=act,
|
|
273
|
+
actions=[ui.Action(id="save", label="Save", icon="bookmark")],
|
|
274
|
+
cls="h-[22rem]",
|
|
275
|
+
),
|
|
276
|
+
padding=False,
|
|
277
|
+
),
|
|
278
|
+
ui.Text("Feedback so far: " + ("; ".join(notes) or "none")),
|
|
279
|
+
)
|
|
280
|
+
```
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Custom components
|
|
3
|
+
order: 10
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Custom components
|
|
7
|
+
|
|
8
|
+
When the catalogue has no component for something, an app writes one in
|
|
9
|
+
JSX. The file lives in the app's `components/` folder, is compiled in the
|
|
10
|
+
browser, and composes the same primitives the catalogue is built from.
|
|
11
|
+
Python reaches it by name with `ui.Custom`.
|
|
12
|
+
|
|
13
|
+
## The contract
|
|
14
|
+
|
|
15
|
+
A component is a default export that receives `props` from Python, `emit`
|
|
16
|
+
to send an event back, `request` to ask one of its handlers and get the
|
|
17
|
+
answer as a promise, `fileUrl` to show a session file, and `children`
|
|
18
|
+
rendered from Python.
|
|
19
|
+
|
|
20
|
+
```jsx
|
|
21
|
+
import { useState } from "react";
|
|
22
|
+
import { Button } from "@/components/ui/button";
|
|
23
|
+
import { Badge } from "@/components/ui/badge";
|
|
24
|
+
import { Sparkles } from "lucide-react";
|
|
25
|
+
|
|
26
|
+
export default function Hello({ props, emit, children }) {
|
|
27
|
+
const [clicks, setClicks] = useState(0);
|
|
28
|
+
return (
|
|
29
|
+
<div className="flex flex-col gap-2 rounded-xl border border-dashed border-primary/40 p-4">
|
|
30
|
+
<div className="flex items-center gap-2">
|
|
31
|
+
<Sparkles className="size-4 text-primary" />
|
|
32
|
+
<span className="font-medium">Hello, {props.who}.</span>
|
|
33
|
+
<Badge variant="secondary">server says {props.count}</Badge>
|
|
34
|
+
<Badge variant="outline">local clicks {clicks}</Badge>
|
|
35
|
+
</div>
|
|
36
|
+
<div className="flex gap-2">
|
|
37
|
+
<Button size="sm" variant="outline" onClick={() => setClicks((c) => c + 1)}>Local +1</Button>
|
|
38
|
+
<Button size="sm" onClick={() => emit("wave", clicks)}>Wave to the server</Button>
|
|
39
|
+
</div>
|
|
40
|
+
{children}
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Using it from Python
|
|
47
|
+
|
|
48
|
+
`ui.Custom(name, props=, events=)` renders `components/<name>.jsx`. Events
|
|
49
|
+
the component emits reach the handler named in `events`, or `on_event` as
|
|
50
|
+
`(name, payload)`.
|
|
51
|
+
|
|
52
|
+
```python live
|
|
53
|
+
from pydantic import BaseModel
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Waves(BaseModel):
|
|
57
|
+
count: int = 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def example(s):
|
|
61
|
+
st = state(Waves)
|
|
62
|
+
|
|
63
|
+
def waved(s, clicks):
|
|
64
|
+
st.count += 1
|
|
65
|
+
s.toast(f"Waved after {clicks} local clicks.")
|
|
66
|
+
|
|
67
|
+
return ui.Custom(
|
|
68
|
+
"Hello",
|
|
69
|
+
ui.Text("Children from Python render inside the custom component.", variant="muted"),
|
|
70
|
+
props={"who": "Ada", "count": st.count},
|
|
71
|
+
events={"wave": waved},
|
|
72
|
+
)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The import map a custom component can draw on is React, lucide icons, and
|
|
76
|
+
every `@/components/ui/*` module the catalogue uses. Tailwind classes work;
|
|
77
|
+
ones the bundle never saw are compiled in the browser on first use.
|
|
78
|
+
|
|
79
|
+
## In a chat's composer
|
|
80
|
+
|
|
81
|
+
`ui.Chat(tools=[...])` puts components in the composer, left of Send: a
|
|
82
|
+
`ui.Button` with a handler in Python, or a custom component, which then
|
|
83
|
+
receives a `chat` prop with `upload(file)`, `send(text, files)` and
|
|
84
|
+
`busy`. That is enough for anything the browser can capture. This voice
|
|
85
|
+
note button records with the browser's own recorder, uploads the clip
|
|
86
|
+
under the chat's `attachments` limits and sends it; the handler gets it as
|
|
87
|
+
`turn.files[0]`. `paperclip=False` keeps the attach button away when the
|
|
88
|
+
tool is the only way files come in, and `capture="environment"` on
|
|
89
|
+
`Accept` makes the paperclip open the camera on a phone.
|
|
90
|
+
|
|
91
|
+
```python live
|
|
92
|
+
async def heard(s, turn):
|
|
93
|
+
async with turn.reply() as reply:
|
|
94
|
+
if turn.files:
|
|
95
|
+
clip = turn.files[0]
|
|
96
|
+
await reply.text(f"A voice note of {clip.size // 1024} KB. Here it is back:")
|
|
97
|
+
await reply.file(clip)
|
|
98
|
+
else:
|
|
99
|
+
await reply.text("Press the microphone and say something.")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
ui.Card(
|
|
103
|
+
ui.Chat(
|
|
104
|
+
key="voice",
|
|
105
|
+
welcome="Record a voice note with the microphone button.",
|
|
106
|
+
attachments=ui.Accept(accept=[".webm", ".ogg", ".mp4", ".m4a"], paperclip=False),
|
|
107
|
+
tools=[ui.Custom("Voice")],
|
|
108
|
+
on_message=heard,
|
|
109
|
+
cls="h-[20rem]",
|
|
110
|
+
),
|
|
111
|
+
padding=False,
|
|
112
|
+
)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
```jsx
|
|
116
|
+
// components/Voice.jsx
|
|
117
|
+
import { useRef, useState } from "react";
|
|
118
|
+
import { Button } from "@/components/ui/button";
|
|
119
|
+
import { Mic, Square } from "lucide-react";
|
|
120
|
+
|
|
121
|
+
export default function Voice({ chat }) {
|
|
122
|
+
const [recorder, setRecorder] = useState(null);
|
|
123
|
+
const chunks = useRef([]);
|
|
124
|
+
if (!chat) return null;
|
|
125
|
+
|
|
126
|
+
async function start() {
|
|
127
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
128
|
+
const r = new MediaRecorder(stream);
|
|
129
|
+
chunks.current = [];
|
|
130
|
+
r.ondataavailable = (e) => chunks.current.push(e.data);
|
|
131
|
+
r.onstop = async () => {
|
|
132
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
133
|
+
const blob = new Blob(chunks.current, { type: r.mimeType || "audio/webm" });
|
|
134
|
+
const ref = await chat.upload(new File([blob], "voice.webm", { type: blob.type }));
|
|
135
|
+
chat.send("", [ref]);
|
|
136
|
+
};
|
|
137
|
+
r.start();
|
|
138
|
+
setRecorder(r);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<Button type="button" size="icon-sm" variant={recorder ? "destructive" : "ghost"}
|
|
143
|
+
onClick={recorder ? () => { recorder.stop(); setRecorder(null); } : start}>
|
|
144
|
+
{recorder ? <Square /> : <Mic />}
|
|
145
|
+
</Button>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
```
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Data
|
|
3
|
+
order: 4
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Data
|
|
7
|
+
|
|
8
|
+
`ui.Table` shows rows: dicts, Pydantic models or dataclasses. Columns come
|
|
9
|
+
from the rows unless `columns` names them, with a `header`, a `format`, a
|
|
10
|
+
`width`, an `align`, and whether they are `sortable` or `filterable`.
|
|
11
|
+
|
|
12
|
+
## A plain table
|
|
13
|
+
|
|
14
|
+
Sorting is on by default and happens in the browser. `row_key` names the
|
|
15
|
+
field that identifies a row, `id` unless told otherwise.
|
|
16
|
+
|
|
17
|
+
```python live
|
|
18
|
+
ROWS = [
|
|
19
|
+
{"id": 1, "name": "Ada", "role": "Engineer", "score": 0.92, "joined": "2024-03-01", "active": True},
|
|
20
|
+
{"id": 2, "name": "Grace", "role": "Admiral", "score": 0.88, "joined": "2023-11-15", "active": True},
|
|
21
|
+
{"id": 3, "name": "Edsger", "role": "Professor", "score": 0.75, "joined": "2022-06-30", "active": False},
|
|
22
|
+
{"id": 4, "name": "Barbara", "role": "Architect", "score": 0.81, "joined": "2024-08-12", "active": True},
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
ui.Table(rows=ROWS)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Columns and formats
|
|
29
|
+
|
|
30
|
+
`format` renders a value as `text`, `number`, `integer`, `percent`,
|
|
31
|
+
`currency`, `date`, `datetime`, `bool`, `badge`, `link` or `code`.
|
|
32
|
+
|
|
33
|
+
```python live
|
|
34
|
+
ui.Table(
|
|
35
|
+
rows=ROWS,
|
|
36
|
+
columns=[
|
|
37
|
+
ui.Column(key="name", header="Name"),
|
|
38
|
+
ui.Column(key="role", header="Role", format="badge"),
|
|
39
|
+
ui.Column(key="score", header="Score", format="percent", align="right"),
|
|
40
|
+
ui.Column(key="joined", header="Joined", format="date"),
|
|
41
|
+
ui.Column(key="active", header="Active", format="bool", align="center"),
|
|
42
|
+
],
|
|
43
|
+
default_sort=[("score", "desc")],
|
|
44
|
+
)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Search, filters and pages
|
|
48
|
+
|
|
49
|
+
`search=True` adds a box that matches any column; `filters=True` adds a
|
|
50
|
+
filter row whose kind follows each column's values; `page_size` pages the
|
|
51
|
+
rows. `summary` is the count line under the table, or custom text.
|
|
52
|
+
|
|
53
|
+
```python live
|
|
54
|
+
ui.Table(rows=ROWS, search=True, filters=True, page_size=3, summary="People in the pilot")
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Selection and row actions
|
|
58
|
+
|
|
59
|
+
`selection="row"` or `"rows"` lets the user pick; `on_select` receives the
|
|
60
|
+
keys. `row_actions` adds a menu per row and `on_action` receives
|
|
61
|
+
`(action id, row key)`. `on_row_click` receives the key of the clicked row.
|
|
62
|
+
|
|
63
|
+
```python live
|
|
64
|
+
from pydantic import BaseModel
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Picked(BaseModel):
|
|
68
|
+
keys: list = []
|
|
69
|
+
last: str = ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def example(s):
|
|
73
|
+
st = state(Picked)
|
|
74
|
+
return ui.Stack(
|
|
75
|
+
ui.Table(
|
|
76
|
+
rows=ROWS,
|
|
77
|
+
columns=[ui.Column(key="name"), ui.Column(key="role")],
|
|
78
|
+
selection="rows",
|
|
79
|
+
selected=st.keys,
|
|
80
|
+
on_select=lambda s, keys: setattr(st, "keys", keys),
|
|
81
|
+
row_actions=[
|
|
82
|
+
ui.MenuItem(id="edit", label="Edit", icon="pencil"),
|
|
83
|
+
ui.MenuItem(id="remove", label="Remove", icon="trash-2", destructive=True),
|
|
84
|
+
],
|
|
85
|
+
on_action=lambda s, a: setattr(st, "last", f"{a[0]} on row {a[1]}"),
|
|
86
|
+
on_row_click=lambda s, key: setattr(st, "last", f"clicked row {key}"),
|
|
87
|
+
),
|
|
88
|
+
ui.Text(f"Selected: {st.keys}. Last: {st.last or 'nothing'}", variant="muted"),
|
|
89
|
+
gap=3,
|
|
90
|
+
)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Tones
|
|
94
|
+
|
|
95
|
+
`tones` colours rows: a function of the row returning a tone, or a mapping
|
|
96
|
+
from column key to a function of the row and the cell.
|
|
97
|
+
|
|
98
|
+
```python live
|
|
99
|
+
ui.Table(
|
|
100
|
+
rows=ROWS,
|
|
101
|
+
columns=[ui.Column(key="name"), ui.Column(key="score", format="percent")],
|
|
102
|
+
tones=lambda r: "success" if r["score"] > 0.85 else ("warning" if r["score"] < 0.8 else None),
|
|
103
|
+
)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Empty
|
|
107
|
+
|
|
108
|
+
`empty` is the text shown when there are no rows.
|
|
109
|
+
|
|
110
|
+
```python live
|
|
111
|
+
ui.Table(rows=[], columns=[ui.Column(key="name"), ui.Column(key="role")], empty="No people match.")
|
|
112
|
+
```
|