@lotics/ui 4.2.0 → 4.4.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/AGENTS.md +79 -6
- package/examples/tpl_assistant.tsx +141 -0
- package/examples/tpl_dieline.tsx +252 -0
- package/examples/tpl_draft.tsx +165 -0
- package/examples/tpl_extract.tsx +186 -0
- package/package.json +15 -2
- package/src/agent_progress.tsx +71 -0
- package/src/agent_run.tsx +194 -0
- package/src/assisted_field.tsx +93 -0
- package/src/change_review.tsx +166 -0
- package/src/clarify.tsx +68 -0
- package/src/confidence.tsx +31 -0
- package/src/inline_edit.tsx +12 -5
- package/src/inline_member_select.tsx +62 -0
- package/src/inline_select.tsx +6 -2
- package/src/prompt_field.tsx +150 -0
- package/src/sources.tsx +92 -0
- package/src/suggestion.tsx +102 -0
package/AGENTS.md
CHANGED
|
@@ -33,7 +33,8 @@ Pick by capability, not by name. (→ the source file for the API.)
|
|
|
33
33
|
(`renderOptionContent={(o) => <OptionBadge value={o} />}`).
|
|
34
34
|
- **Pick member(s)** — `MemberSelect` (a `Picker` that renders each option as a `MemberChip`,
|
|
35
35
|
single or multi) — the ready member picker; pass it the roster (`members={useMembers().members}`).
|
|
36
|
-
Don't re-wire `Picker` + `renderOptionContent` + a directory by hand.
|
|
36
|
+
Don't re-wire `Picker` + `renderOptionContent` + a directory by hand. To edit a `select_member`
|
|
37
|
+
field IN PLACE (chip at rest → picker on click) use its inline-edit twin `InlineMemberSelect`.
|
|
37
38
|
- **A person / member (display)** — `MemberChip` (avatar + name + optional secondary line) — the
|
|
38
39
|
ONE way to show a member inline: a picker option, an assignee, a `select_member` value. Pure:
|
|
39
40
|
resolve the member from your directory (`useMembers`) and pass `name` / `image`; never hand-roll
|
|
@@ -49,7 +50,8 @@ Pick by capability, not by name. (→ the source file for the API.)
|
|
|
49
50
|
`Checkbox`, `Switch`, `RadioPicker`; dates via `DatePicker` / `DateRangeFilterField`, times
|
|
50
51
|
via `TimePicker`.
|
|
51
52
|
- **Edit a record's fields in place** — the `Inline*` family: `InlineTextInput` ·
|
|
52
|
-
`InlineNumberInput` · `InlineSelect` · `
|
|
53
|
+
`InlineNumberInput` · `InlineSelect` · `InlineMemberSelect` · `InlineDatePicker` ·
|
|
54
|
+
`InlineTimePicker` (see §Data entry).
|
|
53
55
|
- **Tabular data** — `Table` (columns defined once; sortable headers via `SortHeader`; paired
|
|
54
56
|
with `Pagination`). Never an HTML `<table>` or a `.map` of rows.
|
|
55
57
|
- **Numbers & charts** — `KPIStrip` / `KPICard` / `Metric` (headline figures), `TrendChip`
|
|
@@ -73,6 +75,12 @@ Pick by capability, not by name. (→ the source file for the API.)
|
|
|
73
75
|
- **Specialized work surfaces** — `ScanField` (scan/verify), `StepList` (guided run),
|
|
74
76
|
`RemainderMeter` + `AllocationRow` (allocation), `Timeline`, `Calendar`, `Gantt`,
|
|
75
77
|
`comments_thread`.
|
|
78
|
+
- **AI surfaces** — `PromptField` (the NL command that triggers agent work — NOT a chat),
|
|
79
|
+
`AgentRun` (the live streaming work feed) + `AgentProgress` (its compact, floating, expandable
|
|
80
|
+
form — a composer's "working" state), `Suggestion` (review-for-approval of a proposal)
|
|
81
|
+
+ `Confidence`, `AssistedField` (a confirmable AI estimate), `ChangeReview` (before→after for an
|
|
82
|
+
agent-proposed edit — whole-set OR 1-by-1), `Clarify` (the agent asks back — a question + quick
|
|
83
|
+
replies), `Sources` (provenance chips for AI output). See §AI workflows.
|
|
76
84
|
|
|
77
85
|
---
|
|
78
86
|
|
|
@@ -101,7 +109,9 @@ hover tints it (no pencil — that shifts), click swaps the input in **at the sa
|
|
|
101
109
|
reflow, the whole point), and it commits on blur (Enter saves, Escape reverts) or via
|
|
102
110
|
`controls="buttons"` (✓ primary / ✕). One per type — `InlineTextInput` · `InlineNumberInput`
|
|
103
111
|
(`format` for currency/units) · `InlineSelect` (plain options OR `renderOptionContent`; floats a
|
|
104
|
-
`Picker` menu in a popover so the row never grows
|
|
112
|
+
`Picker` menu in a popover so the row never grows; `renderValue` renders the RESTING value as a
|
|
113
|
+
node — a colored `OptionBadge`, not just a label) · `InlineMemberSelect` (a `MemberChip` at rest →
|
|
114
|
+
member picker; the inline twin of `MemberSelect`) · `InlineDatePicker` · `InlineTimePicker` — all
|
|
105
115
|
on `useInlineEdit` + `InlineEditView` (custom inputs join via those). `onSave` is async: the
|
|
106
116
|
saving spinner sits INSIDE the control (never a sibling — that reflows); an error shows inline
|
|
107
117
|
without losing the edit. Pair with `DetailRow` (label left, inline value right). Not every field
|
|
@@ -198,6 +208,57 @@ the "this is a gate" framing.
|
|
|
198
208
|
|
|
199
209
|
---
|
|
200
210
|
|
|
211
|
+
## AI workflows — AI proposes, the human decides
|
|
212
|
+
|
|
213
|
+
The AI surfaces share ONE law: the agent never commits — it **proposes**, the human
|
|
214
|
+
accepts / edits / dismisses, the deterministic app applies. The agent owns judgment
|
|
215
|
+
(recognition, estimation, intent→parameters); the app owns geometry, math, and the write.
|
|
216
|
+
Compose the surfaces as a loop, and reach for the right one by job:
|
|
217
|
+
|
|
218
|
+
- **Command** — `PromptField`, NOT a chat composer. A prompt TRIGGERS agent work (Enter sends);
|
|
219
|
+
while it runs the field locks and the send spins. The imperative "do this" surface.
|
|
220
|
+
- **Show the work** — `AgentRun`: a live feed of the agent's steps (reasoning, tool calls) on a
|
|
221
|
+
spine, the running node pulsing violet. Transparent work, NEVER a bare spinner — the
|
|
222
|
+
intelligence is legible. On a canvas/composer app reach for `AgentProgress` — `AgentRun`
|
|
223
|
+
collapsed into a floating pill (avatar + current step) that EXPANDS on press; the composer
|
|
224
|
+
morphs into it while running, and reveals again when done.
|
|
225
|
+
- **Review before apply** — `Suggestion` (a NEW proposed value → accept / edit / dismiss, carrying
|
|
226
|
+
a `Confidence` chip) or `ChangeReview` (an EDIT to existing state → before→after, apply / discard).
|
|
227
|
+
For a shortlist, stack ranked `Suggestion`s (highest confidence first). `ChangeReview` also has a
|
|
228
|
+
**1-by-1** mode — pass `onAcceptItem`/`onRejectItem` and each suggested change gets its own ✓/✕,
|
|
229
|
+
Apply committing only the accepted set (the chat copilot). Nothing auto-applies.
|
|
230
|
+
- **Confirmable estimates** — `AssistedField`: an AI-prefilled value reads as a TINTED ESTIMATE
|
|
231
|
+
until a person confirms or overrides it — provenance stays visible. Stack several with one
|
|
232
|
+
form-level "Confirm all".
|
|
233
|
+
- **Ask back** — `Clarify`: when the agent is unsure, it asks a question with quick-reply options
|
|
234
|
+
and PAUSES, instead of guessing wrong. Human-in-the-loop input mid-run.
|
|
235
|
+
- **Provenance** — `Sources`: openable chips saying where the output came FROM (records, a
|
|
236
|
+
document + page, a table), under any answer / summary / extracted value. Makes AI output
|
|
237
|
+
verifiable — show it on anything produced from data the agent read.
|
|
238
|
+
- **Session, not chat** — the outputs accrue as a history the user can CLEAR ("New session"); the
|
|
239
|
+
APP owns the evolving state, each run is a discrete task. (The agent may re-read the session for
|
|
240
|
+
"make it a bit less", but it's a run LOG, not a conversation transcript — that's why it's not a
|
|
241
|
+
chat.)
|
|
242
|
+
|
|
243
|
+
Four shapes prove the range — pick by whether the work is a living artifact, a conversation, a
|
|
244
|
+
single pass, or generated prose:
|
|
245
|
+
- **Canvas** (`tpl_dieline`) — the page IS the design, a FLOATING composer at the bottom that
|
|
246
|
+
morphs into `AgentProgress` while running; the centre goes empty → processing → result, and a
|
|
247
|
+
prompt re-flows the design in place. For a design/document the user shapes over time.
|
|
248
|
+
- **Chat** (`tpl_assistant`) — a conversation where the agent's answer carries suggested edits the
|
|
249
|
+
user approves/rejects **1-by-1** (`ChangeReview` per-item), applying only the accepted set. An AI
|
|
250
|
+
copilot that proposes changes to a record and never commits on its own.
|
|
251
|
+
- **One-shot** (`tpl_extract`) — document → stream → confirm each extracted field → submit. A
|
|
252
|
+
data-capture pass with no iteration.
|
|
253
|
+
- **Draft** (`tpl_draft`) — the agent writes a first draft from context; the OUTPUT itself streams
|
|
254
|
+
(the text fills in live), then becomes an editable surface the human refines before sending.
|
|
255
|
+
Tone/length steer it. For generated prose — a reply, a quote, a listing.
|
|
256
|
+
|
|
257
|
+
The AI accent is **violet** — distinct from the purpose accents (blue=pipeline, emerald=money), it
|
|
258
|
+
marks what came from the agent.
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
201
262
|
## Composition grammar
|
|
202
263
|
|
|
203
264
|
- **Canvas**: full-bleed `colors.zinc[50]` ScrollView; content column `maxWidth` 880–1040,
|
|
@@ -340,6 +401,12 @@ recipe for a screen JOB; copy and adapt). Pick by the job:
|
|
|
340
401
|
`tpl_timeline` (audit feed).
|
|
341
402
|
- **Planning & time** — `tpl_calendar` · `tpl_attendance` · `tpl_shifts`.
|
|
342
403
|
- **Administration** — `tpl_settings`.
|
|
404
|
+
- **AI workflows** — `tpl_dieline` (the design CANVAS: photo → stream → the dieline reveals at
|
|
405
|
+
centre, the floating composer morphing into `AgentProgress`; prompt to iterate) · `tpl_assistant`
|
|
406
|
+
(a chat copilot: the agent's answer carries suggested edits approved/rejected 1-by-1) ·
|
|
407
|
+
`tpl_extract` (one-shot extract-&-confirm: document → stream → confirm each field → submit) ·
|
|
408
|
+
`tpl_draft` (draft generation: context → the reply text streams into an editable surface →
|
|
409
|
+
refine → send; tone/length steer it).
|
|
343
410
|
|
|
344
411
|
---
|
|
345
412
|
|
|
@@ -354,8 +421,9 @@ status_badge · button · icon_button · link · link_button · pill_button · t
|
|
|
354
421
|
picker · combobox · tag_input (TagInput — chip box + Add-popover; for tags, not Combobox multi) ·
|
|
355
422
|
text_input_field · number_input · search_input · form_field · checkbox · checkbox_input · switch ·
|
|
356
423
|
radio_picker · counter · range_slider · date_picker · date_range_filter_field · time_picker ·
|
|
357
|
-
inline_text_input · inline_number_input · inline_select · inline_date_picker ·
|
|
358
|
-
(the Inline* family — per-field editors on `inline_edit`'s `useInlineEdit` +
|
|
424
|
+
inline_text_input · inline_number_input · inline_select · inline_member_select · inline_date_picker ·
|
|
425
|
+
inline_time_picker (the Inline* family — per-field editors on `inline_edit`'s `useInlineEdit` +
|
|
426
|
+
`InlineEditView`; `InlineSelect`/`InlineMemberSelect` take `renderValue` for a chip/badge at rest) ·
|
|
359
427
|
list · list_item · menu_button · menu_list_item · detail_row · pressable_row · action_menu ·
|
|
360
428
|
floating_action_bar · filter_pill · sort_header · table · pagination · accordion · stepper ·
|
|
361
429
|
step_progress · step_list · timeline · drawer (+ DrawerFooter) · dialog · popover · tooltip ·
|
|
@@ -366,5 +434,10 @@ status_grid (StatusGrid + StatusLegend) · heatmap · legend_item · remainder_m
|
|
|
366
434
|
scan_field · file_dropzone · file_thumbnail · file_thumbnail_grid · file_preview ·
|
|
367
435
|
file_gallery_modal · image_gallery · avatar · skeleton · activity_indicator · loading · divider ·
|
|
368
436
|
spacer · stack · section_card · page_header · page_content · calendar (calendar/index.ts) · gantt ·
|
|
369
|
-
comments_thread ·
|
|
437
|
+
comments_thread · agent_run (live streaming work feed) · agent_progress (its compact floating
|
|
438
|
+
expandable form — a composer's working state) · prompt_field (the NL command surface + optional
|
|
439
|
+
`onAttach`, not a chat) · suggestion (review-for-approval) · confidence (calibrated high/med/low) ·
|
|
440
|
+
assisted_field (confirmable AI estimate) · change_review (before→after — whole-set OR 1-by-1) ·
|
|
441
|
+
clarify (the agent asks back) · sources (provenance chips) ·
|
|
442
|
+
format_money · format_date · colors (solid · tint · ramp · ColorName ·
|
|
370
443
|
isColorName · asColorName — coerce a stored option/status token to a ColorName, neutral fallback).
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { useRef, useState } from "react";
|
|
2
|
+
import { ScrollView, View } from "react-native";
|
|
3
|
+
import { colors, solid, tint } from "@lotics/ui/colors";
|
|
4
|
+
import { Text } from "@lotics/ui/text";
|
|
5
|
+
import { Icon } from "@lotics/ui/icon";
|
|
6
|
+
import { DotsIndicator } from "@lotics/ui/dots_indicator";
|
|
7
|
+
import { PromptField } from "@lotics/ui/prompt_field";
|
|
8
|
+
import { ChangeReview, type ChangeReviewItem, type ChangeReviewItemStatus } from "@lotics/ui/change_review";
|
|
9
|
+
|
|
10
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
// Template · AI assistant — a CHAT workflow, a different shape from the design
|
|
12
|
+
// canvas: a conversation where the agent's answer carries SUGGESTED EDITS the
|
|
13
|
+
// human approves or rejects ONE BY ONE (a before→after diff per change, ✓/✕),
|
|
14
|
+
// then applies only the accepted set. The review-each-edit pattern — an AI
|
|
15
|
+
// copilot proposing changes to a record, never committing on its own. All mock.
|
|
16
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
interface ChatMsg {
|
|
19
|
+
id: string;
|
|
20
|
+
role: "user" | "agent";
|
|
21
|
+
text?: string;
|
|
22
|
+
/** An agent turn may carry suggested edits to review 1-by-1. */
|
|
23
|
+
changes?: ChangeReviewItem[];
|
|
24
|
+
applied?: boolean;
|
|
25
|
+
typing?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const SUGGESTED: Omit<ChangeReviewItem, "status">[] = [
|
|
29
|
+
{ label: "Company", before: "acme packaging co", after: "Acme Packaging Co." },
|
|
30
|
+
{ label: "Phone", before: "0901234567", after: "+84 90 123 4567" },
|
|
31
|
+
{ label: "Email", before: "SALES@ACME.VN", after: "sales@acme.vn" },
|
|
32
|
+
{ label: "Industry", after: "Packaging" },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
export function TplAssistant() {
|
|
36
|
+
const idRef = useRef(1);
|
|
37
|
+
const nextId = () => `m${idRef.current++}`;
|
|
38
|
+
const [messages, setMessages] = useState<ChatMsg[]>([
|
|
39
|
+
{ id: "m0", role: "agent", text: "Paste a record or ask me to tidy one up — I'll propose changes you can approve one by one." },
|
|
40
|
+
]);
|
|
41
|
+
const [decisions, setDecisions] = useState<Record<string, ChangeReviewItemStatus>>({});
|
|
42
|
+
const [prompt, setPrompt] = useState("");
|
|
43
|
+
const [busy, setBusy] = useState(false);
|
|
44
|
+
const [proposed, setProposed] = useState(false);
|
|
45
|
+
|
|
46
|
+
const send = (text: string) => {
|
|
47
|
+
setPrompt("");
|
|
48
|
+
const userMsg: ChatMsg = { id: nextId(), role: "user", text };
|
|
49
|
+
const typingId = nextId();
|
|
50
|
+
setMessages((m) => [...m, userMsg, { id: typingId, role: "agent", typing: true }]);
|
|
51
|
+
setBusy(true);
|
|
52
|
+
setTimeout(() => {
|
|
53
|
+
setBusy(false);
|
|
54
|
+
setMessages((m) =>
|
|
55
|
+
m.map((msg) =>
|
|
56
|
+
msg.id === typingId
|
|
57
|
+
? proposed
|
|
58
|
+
? { id: msg.id, role: "agent", text: "Done — anything else to adjust?" }
|
|
59
|
+
: { id: msg.id, role: "agent", text: "Here's what I'd standardize on this contact. Review each:", changes: SUGGESTED.map((c) => ({ ...c, status: "pending" })) }
|
|
60
|
+
: msg,
|
|
61
|
+
),
|
|
62
|
+
);
|
|
63
|
+
setProposed(true);
|
|
64
|
+
}, 1300);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const decide = (msgId: string, index: number, status: ChangeReviewItemStatus) => {
|
|
68
|
+
setDecisions((d) => ({ ...d, [`${msgId}:${index}`]: status }));
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const applyMsg = (msgId: string) => {
|
|
72
|
+
const msg = messages.find((m) => m.id === msgId);
|
|
73
|
+
const n = (msg?.changes ?? []).filter((_, i) => decisions[`${msgId}:${i}`] === "accepted").length;
|
|
74
|
+
setMessages((m) => [
|
|
75
|
+
...m.map((x) => (x.id === msgId ? { ...x, applied: true } : x)),
|
|
76
|
+
{ id: nextId(), role: "agent", text: `Applied ${n} change${n === 1 ? "" : "s"} to the contact.` },
|
|
77
|
+
]);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<View style={{ flex: 1, backgroundColor: colors.zinc[50] }}>
|
|
82
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 20, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: colors.zinc[200], backgroundColor: colors.background }}>
|
|
83
|
+
<View style={{ width: 24, height: 24, borderRadius: 7, alignItems: "center", justifyContent: "center", backgroundColor: tint("violet", 0.12) }}>
|
|
84
|
+
<Icon name="sparkles" size={13} color={solid("violet")} />
|
|
85
|
+
</View>
|
|
86
|
+
<Text size="sm" weight="semibold">Record assistant</Text>
|
|
87
|
+
</View>
|
|
88
|
+
|
|
89
|
+
<ScrollView contentContainerStyle={{ padding: 20, paddingBottom: 140, gap: 16, maxWidth: 760, width: "100%", alignSelf: "center" }}>
|
|
90
|
+
{messages.map((m) => {
|
|
91
|
+
if (m.role === "user") {
|
|
92
|
+
return (
|
|
93
|
+
<View key={m.id} style={{ alignItems: "flex-end" }}>
|
|
94
|
+
<View style={{ maxWidth: "82%", backgroundColor: colors.zinc[900], borderRadius: 14, paddingHorizontal: 14, paddingVertical: 10 }}>
|
|
95
|
+
<Text size="sm" color="inverted">{m.text}</Text>
|
|
96
|
+
</View>
|
|
97
|
+
</View>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const active = m.changes != null && !m.applied;
|
|
101
|
+
return (
|
|
102
|
+
<View key={m.id} style={{ flexDirection: "row", gap: 10, alignItems: "flex-start" }}>
|
|
103
|
+
<View style={{ width: 26, height: 26, borderRadius: 8, alignItems: "center", justifyContent: "center", backgroundColor: tint("violet", 0.12), marginTop: 1 }}>
|
|
104
|
+
<Icon name="sparkles" size={14} color={solid("violet")} />
|
|
105
|
+
</View>
|
|
106
|
+
<View style={{ flex: 1, gap: 10 }}>
|
|
107
|
+
{m.typing ? (
|
|
108
|
+
<View style={{ paddingVertical: 6 }}><DotsIndicator size={6} color={colors.zinc[400]} /></View>
|
|
109
|
+
) : null}
|
|
110
|
+
{m.text ? <Text size="sm">{m.text}</Text> : null}
|
|
111
|
+
{m.changes ? (
|
|
112
|
+
<ChangeReview
|
|
113
|
+
changes={m.changes.map((c, i) => ({ ...c, status: decisions[`${m.id}:${i}`] ?? "pending" }))}
|
|
114
|
+
status={m.applied ? "applied" : "open"}
|
|
115
|
+
applyLabel="Apply accepted"
|
|
116
|
+
onAcceptItem={active ? (i) => decide(m.id, i, "accepted") : undefined}
|
|
117
|
+
onRejectItem={active ? (i) => decide(m.id, i, "rejected") : undefined}
|
|
118
|
+
onApply={active ? () => applyMsg(m.id) : undefined}
|
|
119
|
+
onDiscard={active ? () => setMessages((ms) => ms.map((x) => (x.id === m.id ? { ...x, applied: true } : x))) : undefined}
|
|
120
|
+
/>
|
|
121
|
+
) : null}
|
|
122
|
+
</View>
|
|
123
|
+
</View>
|
|
124
|
+
);
|
|
125
|
+
})}
|
|
126
|
+
</ScrollView>
|
|
127
|
+
|
|
128
|
+
<View style={{ position: "absolute", left: 0, right: 0, bottom: 20, alignItems: "center", paddingHorizontal: 16 }}>
|
|
129
|
+
<View style={{ width: "100%", maxWidth: 720 }}>
|
|
130
|
+
<PromptField
|
|
131
|
+
value={prompt}
|
|
132
|
+
onChangeText={setPrompt}
|
|
133
|
+
onSubmit={send}
|
|
134
|
+
busy={busy}
|
|
135
|
+
placeholder={proposed ? "Ask for another change…" : "Try: “Tidy up this contact”"}
|
|
136
|
+
/>
|
|
137
|
+
</View>
|
|
138
|
+
</View>
|
|
139
|
+
</View>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { ScrollView, View } from "react-native";
|
|
3
|
+
import { colors, solid, tint } from "@lotics/ui/colors";
|
|
4
|
+
import { Text } from "@lotics/ui/text";
|
|
5
|
+
import { Button } from "@lotics/ui/button";
|
|
6
|
+
import { Badge } from "@lotics/ui/badge";
|
|
7
|
+
import { Icon } from "@lotics/ui/icon";
|
|
8
|
+
import { KPIStrip } from "@lotics/ui/kpi_strip";
|
|
9
|
+
import { DotsIndicator } from "@lotics/ui/dots_indicator";
|
|
10
|
+
import { AgentProgress } from "@lotics/ui/agent_progress";
|
|
11
|
+
import { PromptField } from "@lotics/ui/prompt_field";
|
|
12
|
+
import { type AgentRunStep } from "@lotics/ui/agent_run";
|
|
13
|
+
|
|
14
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
// Template · AI design canvas (the carton "dieline" app) — the FLAGSHIP shape:
|
|
16
|
+
// the whole page is a canvas with the design at its centre and a FLOATING
|
|
17
|
+
// composer at the bottom. Empty → drop a photo in the composer → the centre
|
|
18
|
+
// shows a processing state while the composer MORPHS into a progress pill
|
|
19
|
+
// (AgentProgress — press it to watch the stream) → the dieline reveals and the
|
|
20
|
+
// composer returns → prompt to adjust, the design re-flows in place. The
|
|
21
|
+
// geometry is deterministic (the app owns it); the agent only proposes
|
|
22
|
+
// parameters. All mock — the recipe, not a CAD engine.
|
|
23
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
type ScriptStep = Omit<AgentRunStep, "status">;
|
|
26
|
+
|
|
27
|
+
const RECOGNIZE: ScriptStep[] = [
|
|
28
|
+
{ id: "r1", label: "Reading the photo", detail: "A single brown carton on a white sweep, card left for scale." },
|
|
29
|
+
{ id: "r2", label: "reference_dielines.search", kind: "tool" },
|
|
30
|
+
{ id: "r3", label: "Identifying the box style", detail: "Four flaps meet at the centre seam → Regular Slotted Container, FEFCO 0201." },
|
|
31
|
+
{ id: "r4", label: "Estimating dimensions", detail: "Scaling against the reference card — about 320 × 230 × 150 mm." },
|
|
32
|
+
{ id: "r5", label: "Drafting the dieline", kind: "tool" },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const editScript = (prompt: string): ScriptStep[] => [
|
|
36
|
+
{ id: "e1", label: "Reading the request", detail: prompt },
|
|
37
|
+
{ id: "e2", label: "Recomputing the dieline", kind: "tool" },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
export function TplDieline() {
|
|
41
|
+
const [phase, setPhase] = useState<"empty" | "working" | "done">("empty");
|
|
42
|
+
const [designReady, setDesignReady] = useState(false);
|
|
43
|
+
const [dims, setDims] = useState({ L: 320, W: 230, H: 150 });
|
|
44
|
+
const [prompt, setPrompt] = useState("");
|
|
45
|
+
|
|
46
|
+
// Streaming engine — reveal a run's steps over time, then fire onDone.
|
|
47
|
+
const [run, setRun] = useState<{ script: ScriptStep[]; onDone: () => void } | null>(null);
|
|
48
|
+
const [revealed, setRevealed] = useState(0);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!run) return;
|
|
51
|
+
if (revealed >= run.script.length) {
|
|
52
|
+
const t = setTimeout(() => {
|
|
53
|
+
run.onDone();
|
|
54
|
+
setRun(null);
|
|
55
|
+
setRevealed(0);
|
|
56
|
+
}, 650);
|
|
57
|
+
return () => clearTimeout(t);
|
|
58
|
+
}
|
|
59
|
+
const t = setTimeout(() => setRevealed((r) => r + 1), revealed === 0 ? 60 : 780);
|
|
60
|
+
return () => clearTimeout(t);
|
|
61
|
+
}, [run, revealed]);
|
|
62
|
+
|
|
63
|
+
const liveSteps: AgentRunStep[] = run
|
|
64
|
+
? run.script.slice(0, revealed).map((s, i) => ({
|
|
65
|
+
...s,
|
|
66
|
+
status: revealed < run.script.length && i === revealed - 1 ? "running" : "done",
|
|
67
|
+
}))
|
|
68
|
+
: [];
|
|
69
|
+
|
|
70
|
+
const startRecognize = () => {
|
|
71
|
+
setPhase("working");
|
|
72
|
+
setRevealed(0);
|
|
73
|
+
setRun({
|
|
74
|
+
script: RECOGNIZE,
|
|
75
|
+
onDone: () => {
|
|
76
|
+
setDesignReady(true);
|
|
77
|
+
setPhase("done");
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const submitPrompt = (text: string) => {
|
|
83
|
+
setPrompt("");
|
|
84
|
+
if (!designReady) {
|
|
85
|
+
startRecognize();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
setPhase("working");
|
|
89
|
+
setRevealed(0);
|
|
90
|
+
setRun({
|
|
91
|
+
script: editScript(text),
|
|
92
|
+
onDone: () => {
|
|
93
|
+
const m = /(\d+)\s*mm/i.exec(text);
|
|
94
|
+
const delta = m ? Number(m[1]) : 5;
|
|
95
|
+
setDims((d) => ({ ...d, H: /short|less|low/i.test(text) ? Math.max(20, d.H - delta) : d.H + delta }));
|
|
96
|
+
setPhase("done");
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const newSession = () => {
|
|
102
|
+
setRun(null);
|
|
103
|
+
setRevealed(0);
|
|
104
|
+
setDesignReady(false);
|
|
105
|
+
setDims({ L: 320, W: 230, H: 150 });
|
|
106
|
+
setPhase("empty");
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<View style={{ flex: 1, backgroundColor: colors.zinc[50] }}>
|
|
111
|
+
{/* slim top bar */}
|
|
112
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 20, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: colors.zinc[200], backgroundColor: colors.background }}>
|
|
113
|
+
<Text size="sm" weight="semibold" style={{ flex: 1 }}>Carton design</Text>
|
|
114
|
+
{designReady ? <Badge color="violet" variant="dot" label="FEFCO 0201 · AI-assisted" /> : null}
|
|
115
|
+
{phase !== "empty" ? <Button title="New" color="muted" shape="rounded" icon="rotate-ccw" onPress={newSession} /> : null}
|
|
116
|
+
</View>
|
|
117
|
+
|
|
118
|
+
{/* canvas — the design lives at the centre */}
|
|
119
|
+
<ScrollView contentContainerStyle={{ flexGrow: 1, alignItems: "center", justifyContent: "center", padding: 32, paddingBottom: 180 }}>
|
|
120
|
+
{phase === "empty" ? (
|
|
121
|
+
<EmptyFrame />
|
|
122
|
+
) : phase === "working" && !designReady ? (
|
|
123
|
+
<BuildingFrame />
|
|
124
|
+
) : (
|
|
125
|
+
<View style={{ gap: 16, alignItems: "center", opacity: phase === "working" ? 0.45 : 1 }}>
|
|
126
|
+
<DielinePreview L={dims.L} W={dims.W} H={dims.H} />
|
|
127
|
+
<View style={{ width: 540, maxWidth: "100%" }}>
|
|
128
|
+
<KPIStrip
|
|
129
|
+
items={[
|
|
130
|
+
{ label: "Blank length", value: `${2 * (dims.L + dims.W) + 40} mm` },
|
|
131
|
+
{ label: "Blank width", value: `${dims.H + dims.W} mm` },
|
|
132
|
+
{ label: "Board", value: "B-flute" },
|
|
133
|
+
{ label: "Height", value: `${dims.H} mm` },
|
|
134
|
+
]}
|
|
135
|
+
/>
|
|
136
|
+
</View>
|
|
137
|
+
<View style={{ flexDirection: "row", gap: 8 }}>
|
|
138
|
+
<Button title="Download DXF" color="secondary" shape="rounded" icon="file-down" onPress={() => {}} />
|
|
139
|
+
<Button title="Download PDF" color="secondary" shape="rounded" icon="file-down" onPress={() => {}} />
|
|
140
|
+
</View>
|
|
141
|
+
</View>
|
|
142
|
+
)}
|
|
143
|
+
</ScrollView>
|
|
144
|
+
|
|
145
|
+
{/* floating composer — morphs into the progress pill while the agent runs */}
|
|
146
|
+
<View style={{ position: "absolute", left: 0, right: 0, bottom: 24, alignItems: "center", paddingHorizontal: 16 }}>
|
|
147
|
+
<View style={{ width: "100%", maxWidth: 620 }}>
|
|
148
|
+
{phase === "working" ? (
|
|
149
|
+
<AgentProgress steps={liveSteps} state="streaming" />
|
|
150
|
+
) : (
|
|
151
|
+
<PromptField
|
|
152
|
+
value={prompt}
|
|
153
|
+
onChangeText={setPrompt}
|
|
154
|
+
onSubmit={submitPrompt}
|
|
155
|
+
onAttach={startRecognize}
|
|
156
|
+
placeholder={designReady ? "Describe a change — “make it 5 mm taller”" : "Attach a photo of the carton to start…"}
|
|
157
|
+
/>
|
|
158
|
+
)}
|
|
159
|
+
</View>
|
|
160
|
+
</View>
|
|
161
|
+
</View>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function EmptyFrame() {
|
|
166
|
+
return (
|
|
167
|
+
<View style={{ width: 540, maxWidth: "100%", height: 300, borderRadius: 16, borderWidth: 1.5, borderColor: colors.zinc[300], borderStyle: "dashed", backgroundColor: colors.background, alignItems: "center", justifyContent: "center", gap: 12 }}>
|
|
168
|
+
<View style={{ width: 44, height: 44, borderRadius: 12, alignItems: "center", justifyContent: "center", backgroundColor: tint("violet", 0.1) }}>
|
|
169
|
+
<Icon name="image" size={22} color={solid("violet")} />
|
|
170
|
+
</View>
|
|
171
|
+
<View style={{ alignItems: "center", gap: 2 }}>
|
|
172
|
+
<Text size="md" weight="medium">Your dieline appears here</Text>
|
|
173
|
+
<Text size="sm" color="muted">Attach a photo of the carton in the composer below.</Text>
|
|
174
|
+
</View>
|
|
175
|
+
</View>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function BuildingFrame() {
|
|
180
|
+
return (
|
|
181
|
+
<View style={{ width: 540, maxWidth: "100%", height: 300, borderRadius: 16, borderWidth: 1.5, borderColor: tint("violet", 0.4), borderStyle: "dashed", backgroundColor: tint("violet", 0.03), alignItems: "center", justifyContent: "center", gap: 14 }}>
|
|
182
|
+
<DotsIndicator size={9} color={solid("violet")} />
|
|
183
|
+
<Text size="sm" color="muted">Designing your dieline…</Text>
|
|
184
|
+
</View>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// A schematic RSC dieline, View-based, scaled from the dimensions. Solid edges =
|
|
189
|
+
// cut; dashed = crease. Re-flows whenever a dimension changes.
|
|
190
|
+
function DielinePreview({ L, W, H }: { L: number; W: number; H: number }) {
|
|
191
|
+
const TAB = 40;
|
|
192
|
+
const blankW = 2 * (L + W) + TAB;
|
|
193
|
+
const flap = W / 2;
|
|
194
|
+
const scale = 520 / blankW;
|
|
195
|
+
const px = (mm: number) => Math.max(2, mm * scale);
|
|
196
|
+
const walls = [
|
|
197
|
+
{ w: W, label: "Side" },
|
|
198
|
+
{ w: L, label: "Front" },
|
|
199
|
+
{ w: W, label: "Side" },
|
|
200
|
+
{ w: L, label: "Back" },
|
|
201
|
+
];
|
|
202
|
+
const flapH = px(flap);
|
|
203
|
+
const wallH = px(H);
|
|
204
|
+
const crease = { borderColor: colors.zinc[300], borderStyle: "dashed" as const };
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<View style={{ alignItems: "flex-start", gap: 10 }}>
|
|
208
|
+
<View style={{ borderWidth: 1.5, borderColor: colors.zinc[500], backgroundColor: colors.background }}>
|
|
209
|
+
<View style={{ flexDirection: "row", height: flapH }}>
|
|
210
|
+
{walls.map((p, i) => (
|
|
211
|
+
<View key={`tf-${i}`} style={{ width: px(p.w), borderLeftWidth: i === 0 ? 0 : 1, ...crease, alignItems: "center", justifyContent: "center", backgroundColor: tint("violet", 0.04) }}>
|
|
212
|
+
<Text size="xs" color="muted">flap</Text>
|
|
213
|
+
</View>
|
|
214
|
+
))}
|
|
215
|
+
<View style={{ width: px(TAB) }} />
|
|
216
|
+
</View>
|
|
217
|
+
<View style={{ flexDirection: "row", height: wallH, borderTopWidth: 1, borderBottomWidth: 1, ...crease }}>
|
|
218
|
+
{walls.map((p, i) => (
|
|
219
|
+
<View key={`w-${i}`} style={{ width: px(p.w), borderLeftWidth: i === 0 ? 0 : 1, ...crease, alignItems: "center", justifyContent: "center" }}>
|
|
220
|
+
<Text size="xs" weight="medium">{p.label}</Text>
|
|
221
|
+
<Text size="xs" color="muted" tabular>{p.w} mm</Text>
|
|
222
|
+
</View>
|
|
223
|
+
))}
|
|
224
|
+
<View style={{ width: px(TAB), borderLeftWidth: 1, ...crease, alignItems: "center", justifyContent: "center", backgroundColor: colors.zinc[100] }}>
|
|
225
|
+
<Text size="xs" color="muted">glue</Text>
|
|
226
|
+
</View>
|
|
227
|
+
</View>
|
|
228
|
+
<View style={{ flexDirection: "row", height: flapH }}>
|
|
229
|
+
{walls.map((p, i) => (
|
|
230
|
+
<View key={`bf-${i}`} style={{ width: px(p.w), borderLeftWidth: i === 0 ? 0 : 1, ...crease, alignItems: "center", justifyContent: "center", backgroundColor: tint("violet", 0.04) }}>
|
|
231
|
+
<Text size="xs" color="muted">flap</Text>
|
|
232
|
+
</View>
|
|
233
|
+
))}
|
|
234
|
+
<View style={{ width: px(TAB) }} />
|
|
235
|
+
</View>
|
|
236
|
+
</View>
|
|
237
|
+
<View style={{ flexDirection: "row", gap: 16 }}>
|
|
238
|
+
<Legend dashed label="Crease (score)" />
|
|
239
|
+
<Legend label="Cut" />
|
|
240
|
+
</View>
|
|
241
|
+
</View>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function Legend({ dashed, label }: { dashed?: boolean; label: string }) {
|
|
246
|
+
return (
|
|
247
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
248
|
+
<View style={{ width: 18, borderTopWidth: 1.5, borderColor: dashed ? colors.zinc[300] : colors.zinc[500], borderStyle: dashed ? "dashed" : "solid" }} />
|
|
249
|
+
<Text size="xs" color="muted">{label}</Text>
|
|
250
|
+
</View>
|
|
251
|
+
);
|
|
252
|
+
}
|