@lotics/ui 4.4.0 → 4.5.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 +98 -28
- package/examples/tpl_assistant.tsx +6 -6
- package/examples/tpl_briefing.tsx +121 -0
- package/examples/tpl_compare.tsx +133 -0
- package/examples/tpl_crosscheck.tsx +120 -0
- package/examples/tpl_dieline.tsx +218 -55
- package/examples/tpl_draft.tsx +7 -9
- package/examples/tpl_extract.tsx +91 -92
- package/examples/tpl_lookup.tsx +288 -0
- package/examples/tpl_match.tsx +123 -0
- package/examples/tpl_triage.tsx +112 -0
- package/package.json +9 -2
- package/src/agent_progress.tsx +35 -23
- package/src/agent_run.tsx +60 -80
- package/src/change_review.tsx +190 -110
- package/src/choice_list.tsx +63 -0
- package/src/clarify.tsx +19 -39
- package/src/confidence.tsx +30 -8
- package/src/discrepancy.tsx +114 -0
- package/src/finding.tsx +104 -0
- package/src/match_row.tsx +133 -0
- package/src/prompt_field.tsx +47 -89
- package/src/record_review.tsx +149 -0
- package/src/scored_option.tsx +139 -0
- package/src/sources.tsx +38 -21
- package/src/spec_list.tsx +81 -0
- package/src/suggestion.tsx +35 -45
- package/src/triage_row.tsx +99 -0
- package/src/assisted_field.tsx +0 -93
package/examples/tpl_extract.tsx
CHANGED
|
@@ -3,60 +3,67 @@ import { ScrollView, View } from "react-native";
|
|
|
3
3
|
import { colors } from "@lotics/ui/colors";
|
|
4
4
|
import { Text } from "@lotics/ui/text";
|
|
5
5
|
import { Button } from "@lotics/ui/button";
|
|
6
|
-
import {
|
|
7
|
-
import { Card, CardBody, CardFooter, CardHeader, CardHeaderTitle, CardHeaderMeta } from "@lotics/ui/card";
|
|
6
|
+
import { Card, CardBody, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
|
|
8
7
|
import { FileDropzone } from "@lotics/ui/file_dropzone";
|
|
9
8
|
import { AgentRun, type AgentRunStep } from "@lotics/ui/agent_run";
|
|
10
|
-
import {
|
|
11
|
-
import { InlineTextInput } from "@lotics/ui/inline_text_input";
|
|
9
|
+
import { RecordReview, type RecordField } from "@lotics/ui/record_review";
|
|
12
10
|
import { Sources } from "@lotics/ui/sources";
|
|
13
11
|
import { CompletionState } from "@lotics/ui/completion_state";
|
|
14
12
|
import { type ConfidenceLevel } from "@lotics/ui/confidence";
|
|
15
13
|
|
|
16
14
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
17
|
-
// Template · AI extract & confirm —
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
15
|
+
// Template · AI extract & confirm — read an image, then review each extracted
|
|
16
|
+
// RECORD before it's added to a table. Drop a photo → the agent extracts the
|
|
17
|
+
// lines (AgentRun) → each lands as a `RecordReview` card whose fields are
|
|
18
|
+
// click-to-edit; Confirm collapses it to a tidy checklist row (Undo to re-open),
|
|
19
|
+
// Remove skips it → Save all writes the confirmed lines at once. A one-shot
|
|
20
|
+
// capture pass: nothing is stored until the human has reviewed it. All mock.
|
|
23
21
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
24
22
|
|
|
25
23
|
type ScriptStep = Omit<AgentRunStep, "status">;
|
|
26
24
|
type Phase = "intake" | "extracting" | "review" | "done";
|
|
25
|
+
type Status = "pending" | "confirmed" | "removed";
|
|
27
26
|
|
|
28
27
|
const EXTRACT: ScriptStep[] = [
|
|
29
|
-
{ id: "x1", label: "Reading the
|
|
30
|
-
{ id: "x2", label: "
|
|
31
|
-
{ id: "x3", label: "
|
|
32
|
-
{ id: "x4", label: "
|
|
28
|
+
{ id: "x1", label: "Reading the packing list", detail: "A photo of a 3-line shipping manifest." },
|
|
29
|
+
{ id: "x2", label: "detect_table", kind: "tool" },
|
|
30
|
+
{ id: "x3", label: "Extracting the line items", detail: "Description, HS code, quantity, weight and value per carton." },
|
|
31
|
+
{ id: "x4", label: "classify_hs_codes", kind: "tool" },
|
|
33
32
|
];
|
|
34
33
|
|
|
35
|
-
interface
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
conf: ConfidenceLevel;
|
|
34
|
+
interface RecordData {
|
|
35
|
+
id: string;
|
|
36
|
+
title: string;
|
|
37
|
+
confidence: ConfidenceLevel;
|
|
38
|
+
fields: RecordField[];
|
|
41
39
|
}
|
|
42
|
-
const
|
|
43
|
-
{
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
40
|
+
const RECORDS: RecordData[] = [
|
|
41
|
+
{ id: "r1", title: "Carton 1 — Frozen shrimp", confidence: "high", fields: [
|
|
42
|
+
{ label: "HS code", value: "0306.17.10" },
|
|
43
|
+
{ label: "Quantity", value: "120", unit: "ctn" },
|
|
44
|
+
{ label: "Net weight", value: "1,200", unit: "kg" },
|
|
45
|
+
{ label: "Value (FOB)", value: "86,200,000", unit: "₫" },
|
|
46
|
+
] },
|
|
47
|
+
{ id: "r2", title: "Carton 2 — Cotton T-shirts", confidence: "medium", fields: [
|
|
48
|
+
{ label: "HS code", value: "6109.10.00", uncertain: true },
|
|
49
|
+
{ label: "Quantity", value: "500", unit: "pcs" },
|
|
50
|
+
{ label: "Net weight", value: "340", unit: "kg" },
|
|
51
|
+
{ label: "Value (FOB)", value: "41,300,000", unit: "₫" },
|
|
52
|
+
] },
|
|
53
|
+
{ id: "r3", title: "Carton 3 — Li-ion batteries", confidence: "low", fields: [
|
|
54
|
+
{ label: "HS code", value: "8507.60.10", uncertain: true },
|
|
55
|
+
{ label: "Quantity", value: "48", unit: "pcs" },
|
|
56
|
+
{ label: "Net weight", value: "96", unit: "kg" },
|
|
57
|
+
{ label: "Value (FOB)", value: "18,700,000", unit: "₫" },
|
|
58
|
+
] },
|
|
48
59
|
];
|
|
49
60
|
|
|
50
|
-
const initialValues = () => Object.fromEntries(
|
|
61
|
+
const initialValues = () => Object.fromEntries(RECORDS.map((r) => [r.id, r.fields.map((f) => f.value)]));
|
|
51
62
|
|
|
52
63
|
export function TplExtract() {
|
|
53
64
|
const [phase, setPhase] = useState<Phase>("intake");
|
|
54
|
-
const [
|
|
55
|
-
|
|
56
|
-
// field whose AssistedField has swapped to an inline input (the `onEdit`
|
|
57
|
-
// contract); `values` holds the live value, overridden on save.
|
|
58
|
-
const [values, setValues] = useState<Record<string, string>>(initialValues);
|
|
59
|
-
const [editing, setEditing] = useState<string | null>(null);
|
|
65
|
+
const [statuses, setStatuses] = useState<Record<string, Status>>({});
|
|
66
|
+
const [values, setValues] = useState<Record<string, string[]>>(initialValues);
|
|
60
67
|
|
|
61
68
|
const [revealed, setRevealed] = useState(0);
|
|
62
69
|
const [streaming, setStreaming] = useState(false);
|
|
@@ -86,96 +93,88 @@ export function TplExtract() {
|
|
|
86
93
|
const reset = () => {
|
|
87
94
|
setStreaming(false);
|
|
88
95
|
setRevealed(0);
|
|
89
|
-
|
|
96
|
+
setStatuses({});
|
|
90
97
|
setValues(initialValues());
|
|
91
|
-
setEditing(null);
|
|
92
98
|
setPhase("intake");
|
|
93
99
|
};
|
|
94
100
|
|
|
95
|
-
const
|
|
101
|
+
const setStatus = (id: string, status: Status | undefined) =>
|
|
102
|
+
setStatuses((s) => {
|
|
103
|
+
const n = { ...s };
|
|
104
|
+
if (status) n[id] = status;
|
|
105
|
+
else delete n[id];
|
|
106
|
+
return n;
|
|
107
|
+
});
|
|
108
|
+
const editField = (id: string, index: number, value: string) =>
|
|
109
|
+
setValues((v) => ({ ...v, [id]: v[id].map((x, i) => (i === index ? value : x)) }));
|
|
110
|
+
|
|
111
|
+
const confirmedCount = RECORDS.filter((r) => statuses[r.id] === "confirmed").length;
|
|
96
112
|
|
|
97
113
|
return (
|
|
98
114
|
<ScrollView style={{ backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28, paddingBottom: 120 }}>
|
|
99
115
|
<View style={{ maxWidth: 720, width: "100%", alignSelf: "center", gap: 16 }}>
|
|
100
116
|
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 16 }}>
|
|
101
117
|
<View style={{ flex: 1, gap: 2 }}>
|
|
102
|
-
<Text size="xl" weight="semibold">Capture
|
|
103
|
-
<Text size="sm" color="muted">Drop a
|
|
118
|
+
<Text size="xl" weight="semibold">Capture packing list</Text>
|
|
119
|
+
<Text size="sm" color="muted">Drop a photo; the agent extracts each line, you review and edit, then save them all to the table.</Text>
|
|
104
120
|
</View>
|
|
105
|
-
{phase !== "intake" ? <Button title="Start over" color="muted" shape="rounded"
|
|
121
|
+
{phase !== "intake" ? <Button title="Start over" color="muted" shape="rounded" onPress={reset} /> : null}
|
|
106
122
|
</View>
|
|
107
123
|
|
|
108
124
|
{phase === "intake" ? (
|
|
109
125
|
<Card style={{ padding: 0 }}>
|
|
110
|
-
<CardHeader><CardHeaderTitle info="
|
|
126
|
+
<CardHeader><CardHeaderTitle info="Each extracted line is reviewable and editable before anything is saved.">Source photo</CardHeaderTitle></CardHeader>
|
|
111
127
|
<CardBody>
|
|
112
|
-
<FileDropzone onFiles={start} accept="application/pdf
|
|
128
|
+
<FileDropzone onFiles={start} accept="image/*,application/pdf" height={200} label="Drop a packing list" hint="or click to choose · JPG, PNG, PDF" dropLabel="Drop to extract" />
|
|
113
129
|
</CardBody>
|
|
114
130
|
</Card>
|
|
115
131
|
) : null}
|
|
116
132
|
|
|
117
133
|
{phase === "extracting" ? (
|
|
118
134
|
<Card>
|
|
119
|
-
<AgentRun title="Extracting
|
|
135
|
+
<AgentRun title="Extracting line items" steps={steps} state={streaming ? "streaming" : "done"} />
|
|
120
136
|
</Card>
|
|
121
137
|
) : null}
|
|
122
138
|
|
|
123
139
|
{phase === "review" ? (
|
|
124
|
-
|
|
125
|
-
<
|
|
126
|
-
<
|
|
127
|
-
<
|
|
128
|
-
</
|
|
129
|
-
<
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
estimated={!confirmed[f.key]}
|
|
155
|
-
confidence={f.conf}
|
|
156
|
-
onConfirm={() => setConfirmed((c) => ({ ...c, [f.key]: true }))}
|
|
157
|
-
onEdit={() => setEditing(f.key)}
|
|
158
|
-
/>
|
|
159
|
-
),
|
|
160
|
-
)}
|
|
161
|
-
<Sources
|
|
162
|
-
label="Extracted from"
|
|
163
|
-
onOpen={() => {}}
|
|
164
|
-
sources={[{ id: "doc", label: "invoice-0412.pdf", kind: "document", detail: "2 pages" }]}
|
|
140
|
+
<>
|
|
141
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
142
|
+
<Text size="sm" weight="semibold" style={{ flex: 1 }}>Extracted lines</Text>
|
|
143
|
+
<Text size="xs" color="muted" tabular>{confirmedCount} of {RECORDS.length} confirmed</Text>
|
|
144
|
+
</View>
|
|
145
|
+
<Sources label="Extracted from" onOpen={() => {}} sources={[{ id: "doc", label: "packing-list.jpg", kind: "document", detail: "1 photo" }]} />
|
|
146
|
+
<View style={{ gap: 10 }}>
|
|
147
|
+
{RECORDS.map((r) => (
|
|
148
|
+
<RecordReview
|
|
149
|
+
key={r.id}
|
|
150
|
+
title={r.title}
|
|
151
|
+
confidence={r.confidence}
|
|
152
|
+
status={statuses[r.id] ?? "pending"}
|
|
153
|
+
fields={r.fields.map((f, i) => ({ ...f, value: values[r.id][i] }))}
|
|
154
|
+
onEditField={(i, value) => editField(r.id, i, value)}
|
|
155
|
+
onConfirm={() => setStatus(r.id, "confirmed")}
|
|
156
|
+
onRemove={() => setStatus(r.id, "removed")}
|
|
157
|
+
onUndo={() => setStatus(r.id, undefined)}
|
|
158
|
+
/>
|
|
159
|
+
))}
|
|
160
|
+
</View>
|
|
161
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
162
|
+
<Button title="Confirm all" color="muted" shape="rounded" onPress={() => setStatuses(Object.fromEntries(RECORDS.map((r) => [r.id, "confirmed" as Status])))} />
|
|
163
|
+
<View style={{ flex: 1 }} />
|
|
164
|
+
<Button
|
|
165
|
+
title={`Save ${confirmedCount} ${confirmedCount === 1 ? "line" : "lines"}`}
|
|
166
|
+
color="primary"
|
|
167
|
+
shape="rounded"
|
|
168
|
+
disabled={confirmedCount === 0}
|
|
169
|
+
onPress={() => setPhase("done")}
|
|
165
170
|
/>
|
|
166
|
-
</
|
|
167
|
-
|
|
168
|
-
<View style={{ flex: 1 }}>
|
|
169
|
-
{!allConfirmed ? <LinkButton title="Confirm all" onPress={() => setConfirmed(Object.fromEntries(FIELDS.map((f) => [f.key, true])))} /> : null}
|
|
170
|
-
</View>
|
|
171
|
-
<Button title="Save invoice" color="primary" shape="rounded" disabled={!allConfirmed} onPress={() => setPhase("done")} />
|
|
172
|
-
</CardFooter>
|
|
173
|
-
</Card>
|
|
171
|
+
</View>
|
|
172
|
+
</>
|
|
174
173
|
) : null}
|
|
175
174
|
|
|
176
175
|
{phase === "done" ? (
|
|
177
176
|
<Card>
|
|
178
|
-
<CompletionState title=
|
|
177
|
+
<CompletionState title={`${confirmedCount} lines saved`} summary="The confirmed lines were written to the shipment table.">
|
|
179
178
|
<Button title="Capture another" color="secondary" shape="rounded" onPress={reset} />
|
|
180
179
|
</CompletionState>
|
|
181
180
|
</Card>
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { ScrollView, View } from "react-native";
|
|
3
|
+
import { Text } from "@lotics/ui/text";
|
|
4
|
+
import { colors } from "@lotics/ui/colors";
|
|
5
|
+
import { Divider } from "@lotics/ui/divider";
|
|
6
|
+
import { Callout, CalloutText, CalloutTitle } from "@lotics/ui/callout";
|
|
7
|
+
import { PressableHighlight } from "@lotics/ui/pressable_highlight";
|
|
8
|
+
import { PromptField } from "@lotics/ui/prompt_field";
|
|
9
|
+
import { AgentRun, type AgentRunStep } from "@lotics/ui/agent_run";
|
|
10
|
+
import { SpecList, type SpecRow } from "@lotics/ui/spec_list";
|
|
11
|
+
import { Confidence, type ConfidenceLevel } from "@lotics/ui/confidence";
|
|
12
|
+
import { Sources, type SourceRef } from "@lotics/ui/sources";
|
|
13
|
+
|
|
14
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
// Template · Answer desk — converse on the LEFT, a pinned structured ANSWER on
|
|
16
|
+
// the RIGHT. The AI's verdict isn't prose buried in a chat log: it's a panel of
|
|
17
|
+
// scannable components — a code, the exact duty breakdown (SpecList), the
|
|
18
|
+
// policies that apply, the sources it rested on. You ask, you watch the agent
|
|
19
|
+
// work (AgentRun), the panel pins; a follow-up refines the SAME panel. Same
|
|
20
|
+
// shape for any look-up-and-explain: tariff/HS, fee lookup, policy Q&A, a
|
|
21
|
+
// spec/compliance desk. The structured answer is the product; chat just steers.
|
|
22
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
type Tone = "warning" | "info" | "success";
|
|
25
|
+
interface Policy { tone: Tone; title: string; text: string }
|
|
26
|
+
interface Verdict {
|
|
27
|
+
id: string;
|
|
28
|
+
query: string;
|
|
29
|
+
code: string;
|
|
30
|
+
heading: string;
|
|
31
|
+
confidence: ConfidenceLevel;
|
|
32
|
+
basis: string;
|
|
33
|
+
duty: SpecRow[];
|
|
34
|
+
dutyTotal: SpecRow;
|
|
35
|
+
specs: SpecRow[];
|
|
36
|
+
policies: Policy[];
|
|
37
|
+
sources: SourceRef[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const VERDICTS: Verdict[] = [
|
|
41
|
+
{
|
|
42
|
+
id: "v-shrimp",
|
|
43
|
+
query: "Black tiger shrimp, headless shell-on, IQF frozen, 1 kg retail packs",
|
|
44
|
+
code: "0306.17.10",
|
|
45
|
+
heading: "Frozen shrimp & prawns of the family Penaeidae — not in airtight containers",
|
|
46
|
+
confidence: "high",
|
|
47
|
+
basis:
|
|
48
|
+
"Headless, shell-on, individually-quick-frozen Penaeus monodon → heading 0306.17 (frozen shrimp of the Penaeidae); national line .10 for consumer packs ≤ 1 kg.",
|
|
49
|
+
duty: [
|
|
50
|
+
{ label: "Import duty (MFN)", value: "0%" },
|
|
51
|
+
{ label: "VAT", value: "8%" },
|
|
52
|
+
{ label: "Anti-dumping", value: "0%" },
|
|
53
|
+
],
|
|
54
|
+
dutyTotal: { label: "Effective tax on CIF", value: "8%" },
|
|
55
|
+
specs: [
|
|
56
|
+
{ label: "Chapter", value: "03 — Fish & crustaceans" },
|
|
57
|
+
{ label: "Form", value: "Frozen (IQF)" },
|
|
58
|
+
{ label: "Presentation", value: "Headless, shell-on" },
|
|
59
|
+
{ label: "Net pack", value: "≤ 1 kg" },
|
|
60
|
+
],
|
|
61
|
+
policies: [
|
|
62
|
+
{ tone: "warning", title: "Health certificate required", text: "Aquatic-product imports need a health certificate from the competent authority of the exporting country before clearance." },
|
|
63
|
+
{ tone: "info", title: "Origin can lower the rate", text: "A valid Form D / RCEP certificate of origin keeps the preferential 0% where the MFN line is non-zero." },
|
|
64
|
+
],
|
|
65
|
+
sources: [
|
|
66
|
+
{ id: "s1", label: "Tariff schedule 2026", kind: "knowledge", detail: "Ch. 03" },
|
|
67
|
+
{ id: "s2", label: "Heading 0306.17", kind: "knowledge" },
|
|
68
|
+
{ id: "s3", label: "Circular 31/2022 — VAT", kind: "document", detail: "Art. 1" },
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "v-shirt",
|
|
73
|
+
query: "Men's T-shirts, 100% cotton, knit, short sleeve",
|
|
74
|
+
code: "6109.10.00",
|
|
75
|
+
heading: "T-shirts, singlets & other vests, knitted or crocheted — of cotton",
|
|
76
|
+
confidence: "high",
|
|
77
|
+
basis: "Knitted construction of cotton → heading 6109; subheading .10 for cotton (not man-made fibres).",
|
|
78
|
+
duty: [
|
|
79
|
+
{ label: "Import duty (MFN)", value: "20%" },
|
|
80
|
+
{ label: "ATIGA (Form D)", value: "0%" },
|
|
81
|
+
{ label: "VAT", value: "8%" },
|
|
82
|
+
],
|
|
83
|
+
dutyTotal: { label: "Effective tax on CIF", value: "8–28%" },
|
|
84
|
+
specs: [
|
|
85
|
+
{ label: "Chapter", value: "61 — Knitted apparel" },
|
|
86
|
+
{ label: "Construction", value: "Knit" },
|
|
87
|
+
{ label: "Material", value: "100% cotton" },
|
|
88
|
+
],
|
|
89
|
+
policies: [
|
|
90
|
+
{ tone: "info", title: "Origin proof is decisive", text: "Without a Form D the MFN 20% applies; with it the line drops to 0% — a 20-point swing on CIF." },
|
|
91
|
+
],
|
|
92
|
+
sources: [
|
|
93
|
+
{ id: "s1", label: "Tariff schedule 2026", kind: "knowledge", detail: "Ch. 61" },
|
|
94
|
+
{ id: "s2", label: "ATIGA tariff", kind: "knowledge" },
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
id: "v-batt",
|
|
99
|
+
query: "Lithium-ion battery packs for e-scooters, 48V",
|
|
100
|
+
code: "8507.60.10",
|
|
101
|
+
heading: "Lithium-ion accumulators — of a kind used as the primary source of power",
|
|
102
|
+
confidence: "medium",
|
|
103
|
+
basis: "Secondary (rechargeable) lithium-ion cells assembled into a pack → heading 8507.60. The national line turns on end-use; confirm the device class.",
|
|
104
|
+
duty: [
|
|
105
|
+
{ label: "Import duty (MFN)", value: "5%" },
|
|
106
|
+
{ label: "VAT", value: "10%" },
|
|
107
|
+
],
|
|
108
|
+
dutyTotal: { label: "Effective tax on CIF", value: "15.5%" },
|
|
109
|
+
specs: [
|
|
110
|
+
{ label: "Chapter", value: "85 — Electrical machinery" },
|
|
111
|
+
{ label: "Cell chemistry", value: "Li-ion (rechargeable)" },
|
|
112
|
+
{ label: "Nominal voltage", value: "48 V" },
|
|
113
|
+
],
|
|
114
|
+
policies: [
|
|
115
|
+
{ tone: "warning", title: "Dangerous-goods handling", text: "Lithium batteries ship under UN 3480 — IATA/IMDG packaging and declaration apply; carriers may require a test summary (UN 38.3)." },
|
|
116
|
+
],
|
|
117
|
+
sources: [
|
|
118
|
+
{ id: "s1", label: "Tariff schedule 2026", kind: "knowledge", detail: "Ch. 85" },
|
|
119
|
+
{ id: "s2", label: "Heading 8507.60", kind: "knowledge" },
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
type ScriptStep = Omit<AgentRunStep, "status">;
|
|
125
|
+
const SCRIPT: ScriptStep[] = [
|
|
126
|
+
{ id: "r1", label: "Reading the description", detail: "Form, material, processing and presentation extracted." },
|
|
127
|
+
{ id: "r2", label: "tariff_schedule.search", kind: "tool" },
|
|
128
|
+
{ id: "r3", label: "Narrowing the heading", detail: "Matching the goods against chapter notes and exclusions." },
|
|
129
|
+
{ id: "r4", label: "duty_rates.read", kind: "tool" },
|
|
130
|
+
{ id: "r5", label: "Checking import policies", detail: "Permits, certificates and trade-remedy orders for the line." },
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
export function TplLookup() {
|
|
134
|
+
const [activeId, setActiveId] = useState(VERDICTS[0].id);
|
|
135
|
+
const [phase, setPhase] = useState<"running" | "ready">("ready");
|
|
136
|
+
const [revealed, setRevealed] = useState(SCRIPT.length);
|
|
137
|
+
const [prompt, setPrompt] = useState("");
|
|
138
|
+
const [runKey, setRunKey] = useState(0);
|
|
139
|
+
const timer = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
140
|
+
|
|
141
|
+
const active = VERDICTS.find((v) => v.id === activeId) ?? VERDICTS[0];
|
|
142
|
+
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (runKey === 0) return;
|
|
145
|
+
setPhase("running");
|
|
146
|
+
setRevealed(1);
|
|
147
|
+
let n = 1;
|
|
148
|
+
timer.current = setInterval(() => {
|
|
149
|
+
n += 1;
|
|
150
|
+
setRevealed(n);
|
|
151
|
+
if (n >= SCRIPT.length) {
|
|
152
|
+
if (timer.current) clearInterval(timer.current);
|
|
153
|
+
setPhase("ready");
|
|
154
|
+
}
|
|
155
|
+
}, 700);
|
|
156
|
+
return () => {
|
|
157
|
+
if (timer.current) clearInterval(timer.current);
|
|
158
|
+
};
|
|
159
|
+
}, [runKey]);
|
|
160
|
+
|
|
161
|
+
const ask = (text: string) => {
|
|
162
|
+
const hit =
|
|
163
|
+
VERDICTS.find((v) => {
|
|
164
|
+
const q = text.toLowerCase();
|
|
165
|
+
return (
|
|
166
|
+
(v.id === "v-shirt" && (q.includes("shirt") || q.includes("cotton") || q.includes("knit"))) ||
|
|
167
|
+
(v.id === "v-batt" && (q.includes("batter") || q.includes("lithium") || q.includes("cell"))) ||
|
|
168
|
+
(v.id === "v-shrimp" && (q.includes("shrimp") || q.includes("prawn") || q.includes("frozen")))
|
|
169
|
+
);
|
|
170
|
+
}) ?? VERDICTS[0];
|
|
171
|
+
setActiveId(hit.id);
|
|
172
|
+
setPrompt("");
|
|
173
|
+
setRunKey((k) => k + 1);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const streaming = phase === "running" && revealed < SCRIPT.length;
|
|
177
|
+
const steps: AgentRunStep[] = SCRIPT.slice(0, revealed).map((s, i) => ({
|
|
178
|
+
...s,
|
|
179
|
+
status: streaming && i === revealed - 1 ? "running" : "done",
|
|
180
|
+
}));
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<View style={{ flex: 1, flexDirection: "row", backgroundColor: colors.zinc[50] }}>
|
|
184
|
+
{/* ── LEFT · command + session ───────────────────────────────── */}
|
|
185
|
+
<View style={{ width: 380, backgroundColor: colors.white, borderRightWidth: 1, borderRightColor: colors.zinc[200] }}>
|
|
186
|
+
<View style={{ paddingHorizontal: 20, paddingTop: 18, paddingBottom: 12, gap: 2 }}>
|
|
187
|
+
<Text size="md" weight="semibold">HS classification</Text>
|
|
188
|
+
<Text size="sm" color="muted">Describe the goods — the agent finds the code and explains the duty</Text>
|
|
189
|
+
</View>
|
|
190
|
+
<Divider />
|
|
191
|
+
|
|
192
|
+
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16, gap: 16 }}>
|
|
193
|
+
{/* current run */}
|
|
194
|
+
<AgentRun title={`Classifying — ${active.query}`} steps={steps} state={streaming ? "streaming" : "done"} />
|
|
195
|
+
|
|
196
|
+
<View style={{ gap: 8 }}>
|
|
197
|
+
<Text size="xs" color="muted" weight="medium">This session</Text>
|
|
198
|
+
{VERDICTS.map((v) => (
|
|
199
|
+
<PressableHighlight
|
|
200
|
+
key={v.id}
|
|
201
|
+
onPress={() => { setActiveId(v.id); setPhase("ready"); setRevealed(SCRIPT.length); }}
|
|
202
|
+
accessibilityLabel={`Show ${v.query}`}
|
|
203
|
+
style={{
|
|
204
|
+
padding: 10,
|
|
205
|
+
borderRadius: 10,
|
|
206
|
+
borderWidth: 1,
|
|
207
|
+
borderColor: v.id === activeId ? colors.zinc[900] : colors.zinc[200],
|
|
208
|
+
backgroundColor: v.id === activeId ? colors.zinc[50] : colors.white,
|
|
209
|
+
gap: 3,
|
|
210
|
+
}}
|
|
211
|
+
>
|
|
212
|
+
<Text size="sm" weight="medium" numberOfLines={1}>{v.query}</Text>
|
|
213
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
214
|
+
<Text size="xs" color="muted" tabular>{v.code}</Text>
|
|
215
|
+
<Confidence level={v.confidence} />
|
|
216
|
+
</View>
|
|
217
|
+
</PressableHighlight>
|
|
218
|
+
))}
|
|
219
|
+
</View>
|
|
220
|
+
</ScrollView>
|
|
221
|
+
|
|
222
|
+
<View style={{ padding: 12, borderTopWidth: 1, borderTopColor: colors.zinc[200] }}>
|
|
223
|
+
<PromptField
|
|
224
|
+
value={prompt}
|
|
225
|
+
onChangeText={setPrompt}
|
|
226
|
+
onSubmit={ask}
|
|
227
|
+
busy={phase === "running"}
|
|
228
|
+
placeholder="Describe goods, or refine — “what if it's pre-cooked?”"
|
|
229
|
+
/>
|
|
230
|
+
</View>
|
|
231
|
+
</View>
|
|
232
|
+
|
|
233
|
+
{/* ── RIGHT · the pinned structured answer ───────────────────── */}
|
|
234
|
+
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 28, paddingBottom: 48 }}>
|
|
235
|
+
<View style={{ width: "100%", maxWidth: 720, alignSelf: "center", gap: 16 }}>
|
|
236
|
+
<View style={{ borderWidth: 1, borderColor: colors.zinc[200], borderRadius: 16, backgroundColor: colors.white, overflow: "hidden" }}>
|
|
237
|
+
{/* verdict header */}
|
|
238
|
+
<View style={{ padding: 22, gap: 10, backgroundColor: colors.zinc[50], borderBottomWidth: 1, borderBottomColor: colors.zinc[200] }}>
|
|
239
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
240
|
+
<Text size="xs" color="muted" weight="medium" style={{ flex: 1 }}>Suggested HS code</Text>
|
|
241
|
+
<Confidence level={active.confidence} />
|
|
242
|
+
</View>
|
|
243
|
+
<Text size="xxl" weight="semibold" tabular>{active.code}</Text>
|
|
244
|
+
<Text size="sm" color="muted">{active.heading}</Text>
|
|
245
|
+
</View>
|
|
246
|
+
|
|
247
|
+
<View style={{ padding: 22, gap: 18 }}>
|
|
248
|
+
<View style={{ borderLeftWidth: 2, borderLeftColor: colors.zinc[300], paddingLeft: 12 }}>
|
|
249
|
+
<Text size="sm" color="muted">{active.basis}</Text>
|
|
250
|
+
</View>
|
|
251
|
+
|
|
252
|
+
<View style={{ gap: 8 }}>
|
|
253
|
+
<Text size="sm" weight="semibold">Duty & tax</Text>
|
|
254
|
+
<SpecList rows={active.duty} total={active.dutyTotal} />
|
|
255
|
+
</View>
|
|
256
|
+
|
|
257
|
+
<Divider />
|
|
258
|
+
|
|
259
|
+
<View style={{ gap: 8 }}>
|
|
260
|
+
<Text size="sm" weight="semibold">Classification basis</Text>
|
|
261
|
+
<SpecList rows={active.specs} dense />
|
|
262
|
+
</View>
|
|
263
|
+
|
|
264
|
+
{active.policies.length > 0 ? (
|
|
265
|
+
<View style={{ gap: 10 }}>
|
|
266
|
+
<Text size="sm" weight="semibold">Policies that apply</Text>
|
|
267
|
+
{active.policies.map((p, i) => (
|
|
268
|
+
<Callout key={i} tone={p.tone}>
|
|
269
|
+
<CalloutTitle>{p.title}</CalloutTitle>
|
|
270
|
+
<CalloutText>{p.text}</CalloutText>
|
|
271
|
+
</Callout>
|
|
272
|
+
))}
|
|
273
|
+
</View>
|
|
274
|
+
) : null}
|
|
275
|
+
|
|
276
|
+
<Divider />
|
|
277
|
+
<Sources sources={active.sources} onOpen={() => {}} />
|
|
278
|
+
</View>
|
|
279
|
+
</View>
|
|
280
|
+
|
|
281
|
+
<View style={{ paddingHorizontal: 4 }}>
|
|
282
|
+
<Text size="xs" color="muted">A suggested classification — the declarant confirms the code before filing.</Text>
|
|
283
|
+
</View>
|
|
284
|
+
</View>
|
|
285
|
+
</ScrollView>
|
|
286
|
+
</View>
|
|
287
|
+
);
|
|
288
|
+
}
|