phoenix_duskmoon 9.13.1 → 9.16.0
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.
- package/CHANGELOG.md +18 -0
- package/README.md +34 -5
- package/assets/css/element-theme-bridge.css +0 -3
- package/assets/css/phoenix_duskmoon.css +2 -7
- package/assets/js/react_chat/chat.js +225 -0
- package/assets/js/react_chat/index.js +105 -0
- package/assets/js/react_chat/state.js +84 -0
- package/assets/js/react_form/component.js +40 -0
- package/assets/js/react_form/index.js +117 -0
- package/assets/js/react_form/schema.js +53 -0
- package/assets/js/react_form/values.js +29 -0
- package/package.json +12 -7
- package/priv/static/assets/css/app.css +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
# Unreleased
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- Native Core components: carousel, countdown, diff, keyboard keys, radial progress,
|
|
6
|
+
floating actions, swap, filter group, hero, indicator, join, mask, sidebar layout,
|
|
7
|
+
stack and megamenu, with Storybook examples.
|
|
8
|
+
- Chat scroll container and timeline setting; chip interaction attributes and events.
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- Pin published DuskMoon dependencies to current exact versions: Core/CSS Art
|
|
13
|
+
1.19.9, Elements/Art Elements 1.7.6 and current individual element versions.
|
|
14
|
+
- Use Core spinner/stat styles and remove resolved collapse, highlighting and
|
|
15
|
+
duplicate form-submission workarounds. Preserve existing navigation and overlay APIs.
|
|
16
|
+
- Load Storybook JavaScript as an ES module and resolve workspace assets independently
|
|
17
|
+
of the umbrella reloader's working directory, including hoisted production dependencies.
|
|
18
|
+
|
|
1
19
|
# [9.0.0-rc.1](https://github.com/duskmoon-dev/phoenix-duskmoon-ui/compare/v9.0.0-rc.0...v9.0.0-rc.1) (2026-02-28)
|
|
2
20
|
|
|
3
21
|
|
package/README.md
CHANGED
|
@@ -142,15 +142,44 @@ Components that require hooks:
|
|
|
142
142
|
|
|
143
143
|
## Available Components
|
|
144
144
|
|
|
145
|
-
- **Action**: buttons, dropdowns, links, menus, toggles
|
|
146
|
-
- **Data Display**: accordion, avatar, badge, card, chip, collapse, datetime, flash, git repository, list, markdown, markdown body, pagination, popover, progress, skeleton, stat, table, timeline, tooltip
|
|
147
|
-
- **Data Entry**: autocomplete, cascader, checkbox, compact input, file upload, form, input, multi-select, OTP input, PIN input, radio, rating, segment control, select, slider, switch, textarea, time input, tree select
|
|
145
|
+
- **Action**: buttons, dropdowns, floating actions, links, menus, swaps, toggles
|
|
146
|
+
- **Data Display**: accordion, avatar, badge, card, carousel, chat, chat scroll, chip, collapse, countdown, datetime, diff, flash, git repository, list, markdown, markdown body, pagination, popover, progress, radial progress, skeleton, stat, table, timeline, tooltip
|
|
147
|
+
- **Data Entry**: autocomplete, cascader, checkbox, compact input, file upload, filter group, form, input, multi-select, OTP input, PIN input, radio, rating, segment control, select, slider, switch, textarea, time input, tree select
|
|
148
148
|
- **Feedback**: dialog, loading, snackbar, toast
|
|
149
|
-
- **Navigation**: actionbar, appbar, bottom nav, breadcrumb, left menu,
|
|
150
|
-
- **Layout**: bottom sheet, divider, drawer, theme switcher
|
|
149
|
+
- **Navigation**: actionbar, appbar, bottom nav, breadcrumb, left menu, megamenu, navbar, page footer, page header, stepper, tabs
|
|
150
|
+
- **Layout**: bottom sheet, divider, drawer, sidebar layout, theme switcher
|
|
151
151
|
- **CSS Art**: button noise, eclipse, plasma ball, signature, snow, spotlight search
|
|
152
152
|
|
|
153
153
|
|
|
154
|
+
## Upstream design alignment
|
|
155
|
+
|
|
156
|
+
The current frontend uses Core/CSS Art 1.19.10 and Elements/Art Elements 1.8.0.
|
|
157
|
+
Individually published element dependencies are pinned to their current versions;
|
|
158
|
+
see the [contract audit](apps/phoenix_duskmoon/guides/upstream-design-alignment.md) for the exact set.
|
|
159
|
+
|
|
160
|
+
The new Core primitives render native HTML and use the packaged CSS. Applications
|
|
161
|
+
own countdown timing, filter state, progress values and layout state. Carousel uses
|
|
162
|
+
native scrolling, swap uses a checkbox, floating actions and megamenu
|
|
163
|
+
use the browser Popover API. No element registration is needed for these primitives.
|
|
164
|
+
|
|
165
|
+
```heex
|
|
166
|
+
<div class="indicator">
|
|
167
|
+
<span class="indicator-item badge badge-primary" aria-label="3 unread messages">3</span>
|
|
168
|
+
<button type="button" class="btn">Inbox</button>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<.dm_sidebar_layout>
|
|
172
|
+
<:sidebar><nav aria-label="Workspace">Workspace links</nav></:sidebar>
|
|
173
|
+
<main>Page content</main>
|
|
174
|
+
</.dm_sidebar_layout>
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Existing `dm_menu`, `dm_drawer` and `dm_bottom_sheet` retain their Elements methods
|
|
178
|
+
and events. `dm_loading_spinner` now follows Core's reduced-motion behavior, and
|
|
179
|
+
`dm_stat` uses its semantic stat layout. Chat scroll and chip event examples are
|
|
180
|
+
available in Storybook. Browser `File` objects from chat events require the
|
|
181
|
+
application's upload mechanism; they cannot be sent as LiveView JSON payloads.
|
|
182
|
+
|
|
154
183
|
## Live Storybook
|
|
155
184
|
|
|
156
185
|
[Live Storybook](https://duskmoon-storybook.gsmlg.dev)
|
|
@@ -29,12 +29,10 @@ el-dm-art-snowball-preloader,
|
|
|
29
29
|
el-dm-art-sun,
|
|
30
30
|
el-dm-art-synthwave-starfield,
|
|
31
31
|
el-dm-autocomplete,
|
|
32
|
-
el-dm-badge,
|
|
33
32
|
el-dm-bottom-navigation,
|
|
34
33
|
el-dm-bottom-sheet,
|
|
35
34
|
el-dm-breadcrumbs,
|
|
36
35
|
el-dm-button,
|
|
37
|
-
el-dm-card,
|
|
38
36
|
el-dm-cascader,
|
|
39
37
|
el-dm-chip,
|
|
40
38
|
el-dm-circle-menu,
|
|
@@ -60,7 +58,6 @@ el-dm-progress,
|
|
|
60
58
|
el-dm-segment-control,
|
|
61
59
|
el-dm-select,
|
|
62
60
|
el-dm-slider,
|
|
63
|
-
el-dm-stepper,
|
|
64
61
|
el-dm-switch,
|
|
65
62
|
el-dm-table,
|
|
66
63
|
el-dm-tabs,
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
@source "../../lib/**/*.ex";
|
|
4
4
|
|
|
5
5
|
@import "tailwindcss";
|
|
6
|
+
@import "@duskmoon-dev/core/base.css";
|
|
7
|
+
@import "@duskmoon-dev/components/styles.css";
|
|
6
8
|
|
|
7
9
|
/* DuskMoon UI design system — syntactic sugar for @duskmoon-dev/core */
|
|
8
10
|
@plugin "@duskmoon-dev/core/plugin";
|
|
@@ -11,13 +13,6 @@
|
|
|
11
13
|
@import "@duskmoon-dev/core/components";
|
|
12
14
|
@import "./element-theme-bridge.css";
|
|
13
15
|
|
|
14
|
-
@layer components {
|
|
15
|
-
/* WORKAROUND(upstream): duskmoon-dev/duskmoonui#43 */
|
|
16
|
-
.collapse-card:not(.collapse-open):not(.show) .collapse-content > * {
|
|
17
|
-
padding-block-end: 0;
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
16
|
/* CSS Art — decorative/animated visual effects from @duskmoon-dev/css-art */
|
|
22
17
|
@import "@duskmoon-dev/css-art/dist/art/atom.css";
|
|
23
18
|
@import "@duskmoon-dev/css-art/dist/art/cat-stargazer.css";
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import React, { memo, useEffect, useId, useRef, useState } from "react";
|
|
2
|
+
import { Markdown } from "@duskmoon-dev/components/markdown";
|
|
3
|
+
import { Input } from "@duskmoon-dev/components/input";
|
|
4
|
+
import { Button } from "@duskmoon-dev/components/button";
|
|
5
|
+
import { Chat } from "@duskmoon-dev/components/chat";
|
|
6
|
+
const h = React.createElement;
|
|
7
|
+
|
|
8
|
+
const Message = memo(function Message({ message, bridge, targetId, timeline }) {
|
|
9
|
+
const [notice, setNotice] = useState("");
|
|
10
|
+
return h(
|
|
11
|
+
Chat,
|
|
12
|
+
{
|
|
13
|
+
id: targetId,
|
|
14
|
+
role: "article",
|
|
15
|
+
placement: message.role === "user" ? "end" : "start",
|
|
16
|
+
timeline,
|
|
17
|
+
"data-message-id": message.id,
|
|
18
|
+
},
|
|
19
|
+
h(Chat.Header, null, message.author || message.role),
|
|
20
|
+
h(
|
|
21
|
+
Chat.Bubble,
|
|
22
|
+
{ streaming: message.status === "streaming" },
|
|
23
|
+
...(message.tools || []).map((tool) =>
|
|
24
|
+
h(
|
|
25
|
+
Chat.Tool,
|
|
26
|
+
{ key: tool.id, status: tool.status || "running" },
|
|
27
|
+
h(Chat.ToolHeader, null, `${tool.name || tool.id}: ${tool.status || "running"}`),
|
|
28
|
+
h(
|
|
29
|
+
tool.result == null ? Chat.ToolCall : Chat.ToolResult,
|
|
30
|
+
null,
|
|
31
|
+
h("pre", null, JSON.stringify(tool.result ?? tool.input ?? {}, null, 2)),
|
|
32
|
+
),
|
|
33
|
+
),
|
|
34
|
+
),
|
|
35
|
+
h(Markdown, { className: "chat-bubble-content", markdown: message.content || "" }),
|
|
36
|
+
),
|
|
37
|
+
message.status === "streaming" &&
|
|
38
|
+
h(Chat.Status, { role: "status" }, h(Chat.StatusItem, null, "Generating…")),
|
|
39
|
+
message.error && h("p", { role: "alert" }, message.error),
|
|
40
|
+
h(
|
|
41
|
+
Chat.Footer,
|
|
42
|
+
null,
|
|
43
|
+
h(
|
|
44
|
+
Chat.Actions,
|
|
45
|
+
null,
|
|
46
|
+
h(
|
|
47
|
+
Button,
|
|
48
|
+
{
|
|
49
|
+
type: "button",
|
|
50
|
+
appearance: "ghost",
|
|
51
|
+
onClick: async () => {
|
|
52
|
+
try {
|
|
53
|
+
await navigator.clipboard.writeText(message.content || "");
|
|
54
|
+
setNotice("Copied");
|
|
55
|
+
} catch {
|
|
56
|
+
setNotice("Copy failed");
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
"Copy",
|
|
61
|
+
),
|
|
62
|
+
["error", "cancelled"].includes(message.status) &&
|
|
63
|
+
h(
|
|
64
|
+
Button,
|
|
65
|
+
{
|
|
66
|
+
type: "button",
|
|
67
|
+
appearance: "ghost",
|
|
68
|
+
onClick: () =>
|
|
69
|
+
bridge
|
|
70
|
+
.send("retry", { message_id: message.id })
|
|
71
|
+
.catch((error) => setNotice(error.message)),
|
|
72
|
+
},
|
|
73
|
+
"Retry",
|
|
74
|
+
),
|
|
75
|
+
...(message.actions || []).map((action) =>
|
|
76
|
+
h(
|
|
77
|
+
Button,
|
|
78
|
+
{
|
|
79
|
+
key: action.id,
|
|
80
|
+
type: "button",
|
|
81
|
+
appearance: "ghost",
|
|
82
|
+
onClick: () =>
|
|
83
|
+
bridge
|
|
84
|
+
.send("action", { message_id: message.id, action: action.id })
|
|
85
|
+
.catch((error) => setNotice(error.message)),
|
|
86
|
+
},
|
|
87
|
+
action.label,
|
|
88
|
+
),
|
|
89
|
+
),
|
|
90
|
+
),
|
|
91
|
+
),
|
|
92
|
+
h("span", { role: "status" }, notice),
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
export function ReactChat({ state, bridge, connected, label }) {
|
|
97
|
+
const transcriptId = useId();
|
|
98
|
+
const [draft, setDraft] = useState("");
|
|
99
|
+
const [busy, setBusy] = useState(false);
|
|
100
|
+
const [notice, setNotice] = useState("");
|
|
101
|
+
const pending = useRef(false);
|
|
102
|
+
const alive = useRef(true);
|
|
103
|
+
const scroll = useRef(null);
|
|
104
|
+
const follow = useRef(true);
|
|
105
|
+
useEffect(
|
|
106
|
+
() => () => {
|
|
107
|
+
alive.current = false;
|
|
108
|
+
},
|
|
109
|
+
[],
|
|
110
|
+
);
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (follow.current && scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight;
|
|
113
|
+
}, [state.messages]);
|
|
114
|
+
const streaming = state.messages.some((message) => message.status === "streaming");
|
|
115
|
+
const replies = state.messages.filter((message) => message.role === "assistant").slice(-24);
|
|
116
|
+
const timelines = new Map(replies.map((message, index) => [message.id, index + 1]));
|
|
117
|
+
const targetId = (message) => `${transcriptId}-message-${encodeURIComponent(message.id)}`;
|
|
118
|
+
async function submit(event) {
|
|
119
|
+
event.preventDefault();
|
|
120
|
+
if (!draft.trim() || pending.current || streaming || !connected || state.needsSync) return;
|
|
121
|
+
const text = draft;
|
|
122
|
+
pending.current = true;
|
|
123
|
+
setBusy(true);
|
|
124
|
+
setNotice("");
|
|
125
|
+
try {
|
|
126
|
+
await bridge.send("send", { text, request_id: crypto.randomUUID() });
|
|
127
|
+
if (alive.current) setDraft((current) => (current === text ? "" : current));
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (alive.current) setNotice(error.message);
|
|
130
|
+
} finally {
|
|
131
|
+
pending.current = false;
|
|
132
|
+
if (alive.current) setBusy(false);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return h(
|
|
136
|
+
"section",
|
|
137
|
+
{ className: "flex flex-col gap-3 h-full", "aria-label": label },
|
|
138
|
+
h(
|
|
139
|
+
Chat.Scroll,
|
|
140
|
+
{
|
|
141
|
+
ref: scroll,
|
|
142
|
+
className: "flex-1 min-h-0",
|
|
143
|
+
"data-chat-messages": true,
|
|
144
|
+
onScroll: () => {
|
|
145
|
+
const el = scroll.current;
|
|
146
|
+
follow.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48;
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
h(
|
|
150
|
+
Chat.ScrollTrack,
|
|
151
|
+
{ role: "navigation", "aria-label": "Assistant replies" },
|
|
152
|
+
replies.map((message) =>
|
|
153
|
+
h(Chat.ScrollIndicator, {
|
|
154
|
+
key: message.id,
|
|
155
|
+
targetId: targetId(message),
|
|
156
|
+
timeline: timelines.get(message.id),
|
|
157
|
+
onClick: () => {
|
|
158
|
+
follow.current = false;
|
|
159
|
+
},
|
|
160
|
+
}),
|
|
161
|
+
),
|
|
162
|
+
),
|
|
163
|
+
h(
|
|
164
|
+
Chat.ScrollBody,
|
|
165
|
+
{ className: "gap-3" },
|
|
166
|
+
state.messages.map((message) =>
|
|
167
|
+
h(Message, {
|
|
168
|
+
key: message.id,
|
|
169
|
+
message,
|
|
170
|
+
bridge,
|
|
171
|
+
targetId: targetId(message),
|
|
172
|
+
timeline: timelines.get(message.id),
|
|
173
|
+
}),
|
|
174
|
+
),
|
|
175
|
+
),
|
|
176
|
+
),
|
|
177
|
+
h(
|
|
178
|
+
Button,
|
|
179
|
+
{
|
|
180
|
+
type: "button",
|
|
181
|
+
appearance: "ghost",
|
|
182
|
+
onClick: () => {
|
|
183
|
+
follow.current = true;
|
|
184
|
+
scroll.current.scrollTop = scroll.current.scrollHeight;
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
"Jump to latest",
|
|
188
|
+
),
|
|
189
|
+
(!connected || state.needsSync) &&
|
|
190
|
+
h(
|
|
191
|
+
"p",
|
|
192
|
+
{ role: "status" },
|
|
193
|
+
connected ? "Synchronizing conversation…" : "Disconnected. Draft preserved.",
|
|
194
|
+
),
|
|
195
|
+
notice && h("p", { role: "alert" }, notice),
|
|
196
|
+
h(
|
|
197
|
+
"form",
|
|
198
|
+
{ className: "flex gap-2", onSubmit: submit },
|
|
199
|
+
h(Input, {
|
|
200
|
+
value: draft,
|
|
201
|
+
onChange: (event) => setDraft(event.target.value),
|
|
202
|
+
"aria-label": "Message",
|
|
203
|
+
placeholder: "Write a message",
|
|
204
|
+
}),
|
|
205
|
+
h(
|
|
206
|
+
Button,
|
|
207
|
+
{
|
|
208
|
+
type: "submit",
|
|
209
|
+
disabled: busy || streaming || !connected || state.needsSync || !draft.trim(),
|
|
210
|
+
},
|
|
211
|
+
"Send",
|
|
212
|
+
),
|
|
213
|
+
streaming &&
|
|
214
|
+
h(
|
|
215
|
+
Button,
|
|
216
|
+
{
|
|
217
|
+
type: "button",
|
|
218
|
+
disabled: !connected,
|
|
219
|
+
onClick: () => bridge.send("stop", {}).catch((error) => setNotice(error.message)),
|
|
220
|
+
},
|
|
221
|
+
"Stop",
|
|
222
|
+
),
|
|
223
|
+
),
|
|
224
|
+
);
|
|
225
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { ReactChat } from "./chat.js";
|
|
4
|
+
import { initialState, reduceChat } from "./state.js";
|
|
5
|
+
|
|
6
|
+
export const DuskmoonReactChat = {
|
|
7
|
+
mounted() {
|
|
8
|
+
this.config = JSON.parse(this.el.dataset.chat);
|
|
9
|
+
this.state = initialState(this.config.conversationId, this.config.messages);
|
|
10
|
+
this.root = createRoot(this.el);
|
|
11
|
+
this.connected = true;
|
|
12
|
+
this.requests = new Set();
|
|
13
|
+
this.bridge = { send: (action, payload) => this.sendChat(action, payload) };
|
|
14
|
+
this.eventRef = this.handleEvent(this.config.event, (event) => {
|
|
15
|
+
if (event.id !== this.el.id) return;
|
|
16
|
+
const previous = this.state;
|
|
17
|
+
this.state = reduceChat(this.state, event);
|
|
18
|
+
if (previous === this.state) return;
|
|
19
|
+
if (this.state.needsSync && !previous.needsSync) this.requestSync();
|
|
20
|
+
this.scheduleRender();
|
|
21
|
+
});
|
|
22
|
+
this.renderChat();
|
|
23
|
+
},
|
|
24
|
+
sendChat(action, payload) {
|
|
25
|
+
if (!this.connected || this.dead)
|
|
26
|
+
return Promise.reject(new Error("Chat is disconnected"));
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const finish = (error, reply) => {
|
|
29
|
+
if (!this.requests.delete(request)) return;
|
|
30
|
+
clearTimeout(request.timer);
|
|
31
|
+
error ? reject(error) : resolve(reply);
|
|
32
|
+
};
|
|
33
|
+
const request = {
|
|
34
|
+
cancel: () =>
|
|
35
|
+
finish(new Error("Chat disconnected; check status before retrying")),
|
|
36
|
+
};
|
|
37
|
+
this.requests.add(request);
|
|
38
|
+
request.timer = setTimeout(
|
|
39
|
+
() =>
|
|
40
|
+
finish(
|
|
41
|
+
new Error("Chat request timed out; check status before retrying"),
|
|
42
|
+
),
|
|
43
|
+
15000,
|
|
44
|
+
);
|
|
45
|
+
const body = {
|
|
46
|
+
...payload,
|
|
47
|
+
id: this.el.id,
|
|
48
|
+
conversation_id: this.state.conversationId,
|
|
49
|
+
};
|
|
50
|
+
const reply = (response) =>
|
|
51
|
+
finish(
|
|
52
|
+
response?.status === "ok"
|
|
53
|
+
? null
|
|
54
|
+
: new Error(response?.message || "Chat request rejected"),
|
|
55
|
+
response,
|
|
56
|
+
);
|
|
57
|
+
try {
|
|
58
|
+
const event = this.config.actions[action];
|
|
59
|
+
if (this.config.target)
|
|
60
|
+
this.pushEventTo(this.config.target, event, body, reply);
|
|
61
|
+
else this.pushEvent(event, body, reply);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
finish(error);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
requestSync() {
|
|
68
|
+
this.sendChat("sync", { sequences: this.state.sequences }).catch(() => {});
|
|
69
|
+
},
|
|
70
|
+
scheduleRender() {
|
|
71
|
+
if (!this.frame)
|
|
72
|
+
this.frame = requestAnimationFrame(() => {
|
|
73
|
+
this.frame = null;
|
|
74
|
+
if (!this.dead) this.renderChat();
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
renderChat() {
|
|
78
|
+
this.root.render(
|
|
79
|
+
React.createElement(ReactChat, {
|
|
80
|
+
state: this.state,
|
|
81
|
+
bridge: this.bridge,
|
|
82
|
+
connected: this.connected,
|
|
83
|
+
label: this.config.label,
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
},
|
|
87
|
+
disconnected() {
|
|
88
|
+
this.connected = false;
|
|
89
|
+
this.requests.forEach((request) => request.cancel());
|
|
90
|
+
this.renderChat();
|
|
91
|
+
},
|
|
92
|
+
reconnected() {
|
|
93
|
+
this.connected = true;
|
|
94
|
+
this.state = { ...this.state, needsSync: true };
|
|
95
|
+
this.renderChat();
|
|
96
|
+
this.requestSync();
|
|
97
|
+
},
|
|
98
|
+
destroyed() {
|
|
99
|
+
this.dead = true;
|
|
100
|
+
cancelAnimationFrame(this.frame);
|
|
101
|
+
this.removeHandleEvent(this.eventRef);
|
|
102
|
+
this.requests.forEach((request) => request.cancel());
|
|
103
|
+
this.root.unmount();
|
|
104
|
+
},
|
|
105
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export function initialState(conversationId, messages = []) {
|
|
2
|
+
return {
|
|
3
|
+
conversationId,
|
|
4
|
+
messages,
|
|
5
|
+
sequences: {},
|
|
6
|
+
error: null,
|
|
7
|
+
needsSync: false,
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function reduceChat(state, event) {
|
|
12
|
+
if (event?.conversation_id !== state.conversationId) return state;
|
|
13
|
+
if (["chat.snapshot", "chat.reset"].includes(event.type)) {
|
|
14
|
+
return {
|
|
15
|
+
...initialState(state.conversationId, event.messages || []),
|
|
16
|
+
sequences: event.sequences || {},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
if (
|
|
20
|
+
!event.message_id ||
|
|
21
|
+
!Number.isSafeInteger(event.sequence) ||
|
|
22
|
+
event.sequence < 1
|
|
23
|
+
)
|
|
24
|
+
return state;
|
|
25
|
+
const last = state.sequences[event.message_id] || 0;
|
|
26
|
+
if (event.sequence <= last) return state;
|
|
27
|
+
if (event.sequence !== last + 1) return { ...state, needsSync: true };
|
|
28
|
+
if (state.needsSync) return state;
|
|
29
|
+
const index = state.messages.findIndex(
|
|
30
|
+
(message) => message.id === event.message_id,
|
|
31
|
+
);
|
|
32
|
+
let messages = state.messages;
|
|
33
|
+
if (event.type === "chat.message") {
|
|
34
|
+
if (!event.message || event.message.id !== event.message_id) return state;
|
|
35
|
+
messages =
|
|
36
|
+
index < 0
|
|
37
|
+
? [...messages, event.message]
|
|
38
|
+
: messages.map((m, i) => (i === index ? event.message : m));
|
|
39
|
+
} else {
|
|
40
|
+
if (index < 0) return { ...state, needsSync: true };
|
|
41
|
+
const message = messages[index];
|
|
42
|
+
let next;
|
|
43
|
+
switch (event.type) {
|
|
44
|
+
case "chat.delta":
|
|
45
|
+
if (message.status !== "streaming" || typeof event.text !== "string")
|
|
46
|
+
return state;
|
|
47
|
+
next = { ...message, content: (message.content || "") + event.text };
|
|
48
|
+
break;
|
|
49
|
+
case "chat.tool":
|
|
50
|
+
if (!event.tool?.id) return state;
|
|
51
|
+
next = {
|
|
52
|
+
...message,
|
|
53
|
+
tools: [
|
|
54
|
+
...(message.tools || []).filter(
|
|
55
|
+
(tool) => tool.id !== event.tool.id,
|
|
56
|
+
),
|
|
57
|
+
event.tool,
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
break;
|
|
61
|
+
case "chat.complete":
|
|
62
|
+
next = {
|
|
63
|
+
...message,
|
|
64
|
+
status: event.status === "cancelled" ? "cancelled" : "complete",
|
|
65
|
+
};
|
|
66
|
+
break;
|
|
67
|
+
case "chat.error":
|
|
68
|
+
next = {
|
|
69
|
+
...message,
|
|
70
|
+
status: "error",
|
|
71
|
+
error: event.message || "Generation failed",
|
|
72
|
+
};
|
|
73
|
+
break;
|
|
74
|
+
default:
|
|
75
|
+
return state;
|
|
76
|
+
}
|
|
77
|
+
messages = messages.map((m, i) => (i === index ? next : m));
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
...state,
|
|
81
|
+
messages,
|
|
82
|
+
sequences: { ...state.sequences, [event.message_id]: event.sequence },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import React, { useId } from "react";
|
|
2
|
+
import { Form } from "@duskmoon-dev/components/form";
|
|
3
|
+
import { Input } from "@duskmoon-dev/components/input";
|
|
4
|
+
import { InputNumber } from "@duskmoon-dev/components/input-number";
|
|
5
|
+
import { Checkbox } from "@duskmoon-dev/components/checkbox";
|
|
6
|
+
import { Select } from "@duskmoon-dev/components/select";
|
|
7
|
+
|
|
8
|
+
const h = React.createElement;
|
|
9
|
+
|
|
10
|
+
export function ReactField({ config, value, errors, disabled, onChange }) {
|
|
11
|
+
const errorId = useId();
|
|
12
|
+
const label = config.label || (Array.isArray(config.name) ? config.name.join(".") : config.name);
|
|
13
|
+
const common = { disabled, "aria-label": label, "aria-invalid": errors?.length ? "true" : undefined, "aria-describedby": errors?.length ? errorId : undefined };
|
|
14
|
+
let control;
|
|
15
|
+
|
|
16
|
+
switch (config.type) {
|
|
17
|
+
case "checkbox":
|
|
18
|
+
control = h(Checkbox, { ...common, checked: Boolean(value), onChange: (event) => onChange(event.target.checked) });
|
|
19
|
+
break;
|
|
20
|
+
case "number":
|
|
21
|
+
control = h(InputNumber, { ...common, value: value ?? null, onChange });
|
|
22
|
+
break;
|
|
23
|
+
case "select":
|
|
24
|
+
case "multiselect":
|
|
25
|
+
control = h(Select, { ...common, value: value ?? undefined, mode: config.type === "multiselect" ? "multiple" : undefined,
|
|
26
|
+
options: config.options || [], showSearch: true, allowClear: true, onChange: (next) => onChange(next ?? null) });
|
|
27
|
+
break;
|
|
28
|
+
case "textarea":
|
|
29
|
+
control = h("textarea", { ...common, className: "textarea", value: value ?? "", onChange: (event) => onChange(event.target.value) });
|
|
30
|
+
break;
|
|
31
|
+
default:
|
|
32
|
+
control = h(Input, { ...common, type: config.type, value: value ?? "", onChange: (event) => onChange(event.target.value) });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return h(Form.Item, {
|
|
36
|
+
label,
|
|
37
|
+
className: errors?.length ? "form-item-error" : undefined,
|
|
38
|
+
help: errors?.length ? h(Form.ErrorList, { id: errorId, errors, role: "alert" }) : undefined,
|
|
39
|
+
}, control);
|
|
40
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { ReactField } from "./component.js";
|
|
4
|
+
import { compileForm } from "@duskmoon-dev/components/json-schema-form";
|
|
5
|
+
import { ReactSchemaForm, changedPath, schemaErrors } from "./schema.js";
|
|
6
|
+
import { collectValues, fieldKey, getAt, pathOf, setAt } from "./values.js";
|
|
7
|
+
function parseJSON(value, fallback = {}) { try { return JSON.parse(value); } catch { return fallback; } }
|
|
8
|
+
function flattenErrors(errors, prefix = [], result = {}) {
|
|
9
|
+
Object.entries(errors || {}).forEach(([key, value]) => {
|
|
10
|
+
const path = [...prefix, key];
|
|
11
|
+
if (Array.isArray(value)) result[path.join(".")] = value;
|
|
12
|
+
else if (value && typeof value === "object") flattenErrors(value, path, result);
|
|
13
|
+
});
|
|
14
|
+
return result;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const DuskmoonReactForm = {
|
|
18
|
+
mounted() {
|
|
19
|
+
this.revision = 0; this.roots = new Map(); this.disconnectedState = false;
|
|
20
|
+
this.schemaElement = this.el.querySelector("[data-dm-react-schema]");
|
|
21
|
+
if (this.schemaElement) this.mountSchema();
|
|
22
|
+
else this.mountForm();
|
|
23
|
+
this.handleEvent("dm:form:reset", (payload) => {
|
|
24
|
+
if (payload?.id === this.el.id) this.resetForm(payload.values ?? this.initialValues);
|
|
25
|
+
});
|
|
26
|
+
},
|
|
27
|
+
mountForm() {
|
|
28
|
+
this.form = this.el.querySelector("[data-dm-react-form]");
|
|
29
|
+
const nativeValues = collectValues([...this.form.querySelectorAll("[name]")]);
|
|
30
|
+
this.values = { ...nativeValues, ...parseJSON(this.el.dataset.initialValues) };
|
|
31
|
+
this.initialValues = structuredClone(this.values);
|
|
32
|
+
this.fields = [...this.el.querySelectorAll("[data-dm-react-field]")];
|
|
33
|
+
const fieldKeys = this.fields.map((element) => fieldKey(parseJSON(element.dataset.dmReactField).name));
|
|
34
|
+
if (new Set(fieldKeys).size !== fieldKeys.length) throw new Error("React form field names must be unique");
|
|
35
|
+
this.mountFields(); this.bindNative(); this.bindForm();
|
|
36
|
+
},
|
|
37
|
+
mountSchema() {
|
|
38
|
+
this.compiledSchema = compileForm(JSON.parse(this.el.dataset.schema));
|
|
39
|
+
this.values = parseJSON(this.el.dataset.initialValues, this.compiledSchema.initialValue());
|
|
40
|
+
this.initialValues = structuredClone(this.values);
|
|
41
|
+
this.schemaRevision = 0; this.schemaRoot = createRoot(this.schemaElement);
|
|
42
|
+
this.renderSchema();
|
|
43
|
+
},
|
|
44
|
+
renderSchema() {
|
|
45
|
+
this.schemaRoot.render(React.createElement(ReactSchemaForm, {
|
|
46
|
+
key: this.schemaRevision, compiled: this.compiledSchema, value: this.values,
|
|
47
|
+
defaultValue: this.initialValues, errors: this.errors, disabled: this.disconnectedState,
|
|
48
|
+
onChange: (values) => {
|
|
49
|
+
const path = changedPath(this.values, values);
|
|
50
|
+
this.values = structuredClone(values); this.errors = {}; this.renderAll(); this.scheduleChange(path);
|
|
51
|
+
},
|
|
52
|
+
onErrorsChange: () => { this.errors = {}; this.renderAll(); },
|
|
53
|
+
onSubmit: (values) => { this.values = structuredClone(values); this.submit(); },
|
|
54
|
+
onReset: () => this.resetForm(),
|
|
55
|
+
}));
|
|
56
|
+
},
|
|
57
|
+
mountFields() {
|
|
58
|
+
this.fields.forEach((element) => {
|
|
59
|
+
const config = parseJSON(element.dataset.dmReactField);
|
|
60
|
+
const key = fieldKey(config.name); const root = createRoot(element);
|
|
61
|
+
this.roots.set(key, { config, root }); this.renderField(element);
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
renderField(element) {
|
|
65
|
+
const config = parseJSON(element.dataset.dmReactField); const entry = this.roots.get(fieldKey(config.name));
|
|
66
|
+
entry?.root.render(React.createElement(ReactField, {
|
|
67
|
+
config, value: getAt(this.values, config.name), errors: this.errors?.[fieldKey(config.name)], disabled: this.disconnectedState,
|
|
68
|
+
onChange: (value) => this.change(config.name, value),
|
|
69
|
+
}));
|
|
70
|
+
},
|
|
71
|
+
bindNative() {
|
|
72
|
+
this.native = [...this.form.querySelectorAll("[name]")].filter((element) => !element.closest("[data-dm-react-field]"));
|
|
73
|
+
this.native.forEach((element) => {
|
|
74
|
+
const handler = () => this.change(element.name, this.nativeValue(element));
|
|
75
|
+
element.addEventListener("input", handler); element.addEventListener("change", handler); element.__dmReactHandler = handler;
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
nativeValue(element) {
|
|
79
|
+
if (element.type === "checkbox") return element.checked;
|
|
80
|
+
if (element.type === "number") return element.value === "" ? null : Number(element.value);
|
|
81
|
+
if (element.multiple) return [...element.selectedOptions].map((option) => option.value);
|
|
82
|
+
return element.value;
|
|
83
|
+
},
|
|
84
|
+
bindForm() {
|
|
85
|
+
this.submitHandler = (event) => { event.preventDefault(); this.submit(); };
|
|
86
|
+
this.resetHandler = () => setTimeout(() => this.resetForm(), 0);
|
|
87
|
+
this.form.addEventListener("submit", this.submitHandler); this.form.addEventListener("reset", this.resetHandler);
|
|
88
|
+
},
|
|
89
|
+
change(name, value) {
|
|
90
|
+
this.values = setAt(this.values, name, value); this.errors = { ...this.errors, [fieldKey(name)]: [] }; this.renderAll();
|
|
91
|
+
this.scheduleChange(name);
|
|
92
|
+
},
|
|
93
|
+
scheduleChange(name) {
|
|
94
|
+
const event = this.el.getAttribute("phx-change"); if (!event || this.disconnectedState) return;
|
|
95
|
+
clearTimeout(this.changeTimer); this.changeTimer = setTimeout(() => this.send(event, name), Number(this.el.getAttribute("phx-debounce") || 0));
|
|
96
|
+
},
|
|
97
|
+
submit() { clearTimeout(this.changeTimer); const event = this.el.getAttribute("phx-submit"); if (event && !this.disconnectedState) this.send(event); },
|
|
98
|
+
send(event, changed) {
|
|
99
|
+
const revision = ++this.revision; const values = this.values;
|
|
100
|
+
const payload = { id: this.el.id, values, revision, ...(changed ? { changed: pathOf(changed) } : {}) };
|
|
101
|
+
const target = this.el.getAttribute("phx-target"); const callback = (reply) => {
|
|
102
|
+
if (this.destroyedState || revision !== this.revision || values !== this.values || !reply) return;
|
|
103
|
+
this.errors = this.schemaRoot ? schemaErrors(reply.errors) : flattenErrors(reply.errors); this.renderAll();
|
|
104
|
+
};
|
|
105
|
+
if (target) this.pushEventTo(target, event, payload, callback); else this.pushEvent(event, payload, callback);
|
|
106
|
+
},
|
|
107
|
+
renderAll() { if (this.schemaRoot) this.renderSchema(); else this.fields.forEach((element) => this.renderField(element)); },
|
|
108
|
+
resetForm(values = this.initialValues) {
|
|
109
|
+
clearTimeout(this.changeTimer);
|
|
110
|
+
this.values = structuredClone(values); this.errors = {}; this.revision++;
|
|
111
|
+
if (this.schemaRoot) { this.schemaRevision++; this.renderAll(); return; }
|
|
112
|
+
this.native.forEach((element) => { if (element.type === "checkbox") element.checked = Boolean(getAt(this.values, element.name)); else element.value = getAt(this.values, element.name) ?? ""; }); this.renderAll();
|
|
113
|
+
},
|
|
114
|
+
disconnected() { clearTimeout(this.changeTimer); this.disconnectedState = true; this.renderAll(); },
|
|
115
|
+
reconnected() { this.disconnectedState = false; this.renderAll(); },
|
|
116
|
+
destroyed() { this.destroyedState = true; clearTimeout(this.changeTimer); this.schemaRoot?.unmount(); this.form?.removeEventListener("submit", this.submitHandler); this.form?.removeEventListener("reset", this.resetHandler); this.native?.forEach((element) => { element.removeEventListener("input", element.__dmReactHandler); element.removeEventListener("change", element.__dmReactHandler); }); this.roots.forEach(({ root }) => root.unmount()); },
|
|
117
|
+
};
|