@alexkroman1/aai-cli 5.3.0 → 5.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.
Files changed (99) hide show
  1. package/README.md +51 -0
  2. package/dist/{_agent-D8zBb3M5.mjs → _agent-DMyOab9_.mjs} +2 -2
  3. package/dist/{_bundler-DFS4xxqE.mjs → _bundler-Cjaxa2wi.mjs} +1 -1
  4. package/dist/{_config-D3F9km8X.mjs → _config-5AEqhh-O.mjs} +2 -2
  5. package/dist/{_dev-server-BBUWo0sb.mjs → _dev-server-vV05Fnki.mjs} +3 -3
  6. package/dist/{_init-DU-sXH6S.mjs → _init-BZ9t_Kz-.mjs} +3 -56
  7. package/dist/{_slug-api-19R5kZ0U.mjs → _slug-api-DaqQJHk8.mjs} +1 -1
  8. package/dist/_templates-Bv8CR800.mjs +69 -0
  9. package/dist/_templates.d.ts +6 -0
  10. package/dist/{build-Xq1xxoSg.mjs → build-D_PgQOD4.mjs} +1 -1
  11. package/dist/cli.mjs +59 -17
  12. package/dist/{client-bundler-BWDkUeEP.mjs → client-bundler-yiWoXrgb.mjs} +3 -0
  13. package/dist/client-bundler.d.ts +9 -0
  14. package/dist/client-bundler.mjs +1 -1
  15. package/dist/{delete-kSXFLxek.mjs → delete-DXilFBb1.mjs} +1 -1
  16. package/dist/{deploy-Ci0X1gp2.mjs → deploy-1eaXcfUw.mjs} +3 -3
  17. package/dist/{dev-g7sGt8qX.mjs → dev-gVNdGFYY.mjs} +1 -1
  18. package/dist/{init-BjeK8crW.mjs → init-DoU4_txp.mjs} +3 -3
  19. package/dist/login-C59ZHzuO.mjs +109 -0
  20. package/dist/login.d.ts +34 -0
  21. package/dist/scaffold/CLAUDE.md +190 -120
  22. package/dist/scaffold/package.json +3 -3
  23. package/dist/{secret-DOva9OGk.mjs → secret-CGAIAbUx.mjs} +1 -1
  24. package/dist/{storage-vM6HjDZZ.mjs → storage-CnhOayhm.mjs} +1 -1
  25. package/dist/templates/code-interpreter/agent.ts +1 -2
  26. package/dist/templates/dispatch-center/agent.test.ts +3 -3
  27. package/dist/templates/dispatch-center/agent.ts +7 -7
  28. package/dist/templates/dispatch-center/client.tsx +7 -2
  29. package/dist/templates/dispatch-center/tools/incident_add_note.ts +1 -1
  30. package/dist/templates/dispatch-center/tools/incident_create.ts +1 -1
  31. package/dist/templates/dispatch-center/tools/incident_escalate.ts +1 -1
  32. package/dist/templates/dispatch-center/tools/incident_get.ts +1 -1
  33. package/dist/templates/dispatch-center/tools/incident_triage.ts +1 -1
  34. package/dist/templates/dispatch-center/tools/incident_update_status.ts +1 -1
  35. package/dist/templates/dispatch-center/tools/ops_protocols.ts +1 -1
  36. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +1 -1
  37. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +1 -1
  38. package/dist/templates/dispatch-center/tools/resources_get_available.ts +1 -1
  39. package/dist/templates/dispatch-center/tools/resources_update_status.ts +1 -1
  40. package/dist/templates/embedded-assets/agent.ts +2 -3
  41. package/dist/templates/health-assistant/agent.ts +3 -4
  42. package/dist/templates/infocom-adventure/agent.ts +10 -12
  43. package/dist/templates/infocom-adventure/client.tsx +5 -5
  44. package/dist/templates/math-buddy/agent.ts +7 -6
  45. package/dist/templates/night-owl/agent.ts +2 -3
  46. package/dist/templates/personal-finance/agent.ts +1 -2
  47. package/dist/templates/pipeline-simple/agent.test.ts +13 -13
  48. package/dist/templates/pipeline-simple/agent.ts +5 -6
  49. package/dist/templates/pizza-ordering/agent.test.ts +13 -11
  50. package/dist/templates/pizza-ordering/agent.ts +5 -6
  51. package/dist/templates/retail/address.ts +29 -0
  52. package/dist/templates/retail/agent.test.ts +1030 -0
  53. package/dist/templates/retail/agent.ts +71 -0
  54. package/dist/templates/retail/authenticate.ts +34 -0
  55. package/dist/templates/retail/client.tsx +459 -0
  56. package/dist/templates/retail/refund.ts +19 -0
  57. package/dist/templates/retail/registry.test.ts +182 -0
  58. package/dist/templates/retail/resolve.test.ts +158 -0
  59. package/dist/templates/retail/resolve.ts +158 -0
  60. package/dist/templates/retail/seed.json +4260 -0
  61. package/dist/templates/retail/seed.test.ts +224 -0
  62. package/dist/templates/retail/shared.test.ts +192 -0
  63. package/dist/templates/retail/shared.ts +430 -0
  64. package/dist/templates/retail/store.test.ts +256 -0
  65. package/dist/templates/retail/store.ts +243 -0
  66. package/dist/templates/retail/swap.test.ts +186 -0
  67. package/dist/templates/retail/swap.ts +138 -0
  68. package/dist/templates/retail/system-prompt.md +111 -0
  69. package/dist/templates/retail/tools/cancel_pending_order.ts +82 -0
  70. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +83 -0
  71. package/dist/templates/retail/tools/find_user_id_by_email.ts +32 -0
  72. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +38 -0
  73. package/dist/templates/retail/tools/get_item_details.ts +29 -0
  74. package/dist/templates/retail/tools/get_order_details.ts +41 -0
  75. package/dist/templates/retail/tools/get_product_details.ts +33 -0
  76. package/dist/templates/retail/tools/get_user_details.ts +44 -0
  77. package/dist/templates/retail/tools/list_all_product_types.ts +24 -0
  78. package/dist/templates/retail/tools/modify_pending_order_address.ts +46 -0
  79. package/dist/templates/retail/tools/modify_pending_order_items.ts +102 -0
  80. package/dist/templates/retail/tools/modify_pending_order_payment.ts +103 -0
  81. package/dist/templates/retail/tools/modify_user_address.ts +38 -0
  82. package/dist/templates/retail/tools/return_delivered_order_items.ts +101 -0
  83. package/dist/templates/retail/tools/transfer_to_human_agents.ts +22 -0
  84. package/dist/templates/simple/agent.test.ts +18 -0
  85. package/dist/templates/simple/agent.ts +3 -0
  86. package/dist/templates/solo-rpg/agent.test.ts +2 -2
  87. package/dist/templates/solo-rpg/agent.ts +1 -2
  88. package/dist/templates/solo-rpg/tools/action_roll.ts +1 -1
  89. package/dist/templates/solo-rpg/tools/load_game.ts +1 -1
  90. package/dist/templates/solo-rpg/tools/oracle.ts +1 -1
  91. package/dist/templates/solo-rpg/tools/save_game.ts +1 -1
  92. package/dist/templates/solo-rpg/tools/setup_character.ts +1 -1
  93. package/dist/templates/solo-rpg/tools/update_state.ts +1 -1
  94. package/dist/templates/web-researcher/agent.ts +1 -2
  95. package/dist/typecheck.d.ts +18 -0
  96. package/dist/typecheck.mjs +8 -0
  97. package/dist/worker-bundler.d.ts +9 -1
  98. package/dist/worker-bundler.mjs +8 -0
  99. package/package.json +3 -3
@@ -0,0 +1,71 @@
1
+ import { agent } from "@alexkroman1/aai";
2
+ import { assemblyAIStt } from "@alexkroman1/aai/stt";
3
+ import type { StateSlot } from "./shared.ts";
4
+ import { storeView } from "./shared.ts";
5
+ import { createDefaultState } from "./store.ts";
6
+ import systemPrompt from "./system-prompt.md?raw";
7
+ import { cancelPendingOrder } from "./tools/cancel_pending_order.ts";
8
+ import { exchangeDeliveredOrderItems } from "./tools/exchange_delivered_order_items.ts";
9
+ import { findUserIdByEmail } from "./tools/find_user_id_by_email.ts";
10
+ import { findUserIdByNameZip } from "./tools/find_user_id_by_name_zip.ts";
11
+ import { getItemDetails } from "./tools/get_item_details.ts";
12
+ import { getOrderDetails } from "./tools/get_order_details.ts";
13
+ import { getProductDetails } from "./tools/get_product_details.ts";
14
+ import { getUserDetails } from "./tools/get_user_details.ts";
15
+ import { listAllProductTypes } from "./tools/list_all_product_types.ts";
16
+ import { modifyPendingOrderAddress } from "./tools/modify_pending_order_address.ts";
17
+ import { modifyPendingOrderItems } from "./tools/modify_pending_order_items.ts";
18
+ import { modifyPendingOrderPayment } from "./tools/modify_pending_order_payment.ts";
19
+ import { modifyUserAddress } from "./tools/modify_user_address.ts";
20
+ import { returnDeliveredOrderItems } from "./tools/return_delivered_order_items.ts";
21
+ import { transferToHumanAgents } from "./tools/transfer_to_human_agents.ts";
22
+
23
+ export default agent({
24
+ name: "Retail Support",
25
+
26
+ // No provider spread: pipeline mode is the default, and an unset stage is
27
+ // filled from the all-AssemblyAI pipeline at parse time. Only `stt` is
28
+ // overridden below; `llm` and `tts` take the defaults.
29
+
30
+ // The store lives in ctx.state, one pristine copy per session — callers must
31
+ // not see each other's cancellations. Wrapped in the `StateSlot` shape
32
+ // `store.ts`'s `getState`/`shared.ts`'s `storeView` both key off — `agent()`
33
+ // infers its session-state type parameter from BOTH `state` and `syncState`,
34
+ // and `StateSlot`'s `retail` field is optional (a "weak type"), so handing
35
+ // it a bare `RetailState` with no properties in common fails that inference
36
+ // rather than an ordinary structural check.
37
+ state: (): StateSlot => ({ retail: createDefaultState() }),
38
+
39
+ // One projection pushed after every tool call. It is a projection, not a
40
+ // flag, because the state holds all six seeded customers and only the
41
+ // authenticated one may reach the browser.
42
+ syncState: storeView,
43
+
44
+ // Callers read order numbers and ten-digit item numbers in bursts with
45
+ // pauses inside one utterance, so end-of-turn silence has to be longer than
46
+ // the default or "W seven six seven … eight oh seven two" splits in two.
47
+ stt: assemblyAIStt({ minTurnSilenceMs: 2200 }),
48
+
49
+ systemPrompt,
50
+ greeting:
51
+ "Thanks for calling. Before I can look anything up I'll need to find your account — " +
52
+ "what's the email address on it?",
53
+
54
+ tools: {
55
+ cancel_pending_order: cancelPendingOrder,
56
+ exchange_delivered_order_items: exchangeDeliveredOrderItems,
57
+ find_user_id_by_email: findUserIdByEmail,
58
+ find_user_id_by_name_zip: findUserIdByNameZip,
59
+ get_item_details: getItemDetails,
60
+ get_order_details: getOrderDetails,
61
+ get_product_details: getProductDetails,
62
+ get_user_details: getUserDetails,
63
+ list_all_product_types: listAllProductTypes,
64
+ modify_pending_order_address: modifyPendingOrderAddress,
65
+ modify_pending_order_items: modifyPendingOrderItems,
66
+ modify_pending_order_payment: modifyPendingOrderPayment,
67
+ modify_user_address: modifyUserAddress,
68
+ return_delivered_order_items: returnDeliveredOrderItems,
69
+ transfer_to_human_agents: transferToHumanAgents,
70
+ },
71
+ });
@@ -0,0 +1,34 @@
1
+ import type { RetailState, User } from "./shared.ts";
2
+ import type { ErrorResult } from "./store.ts";
3
+
4
+ export interface AuthResult {
5
+ user_id: string;
6
+ name: string;
7
+ order_count: number;
8
+ }
9
+
10
+ /**
11
+ * Latch the session onto one customer.
12
+ *
13
+ * Switching to a different customer is refused. tau2 keeps "you can only help
14
+ * one user per conversation" in the prompt, which leaves the ownership guard on
15
+ * every other tool trivially bypassable — just re-authenticate as whoever's
16
+ * order you want. Re-running a finder for the SAME customer succeeds, because a
17
+ * caller repeating their email should not hit an error.
18
+ */
19
+ export function authenticateAs(state: RetailState, user: User): AuthResult | ErrorResult {
20
+ const current = state.authenticatedUserId;
21
+ if (current && current !== user.user_id) {
22
+ return {
23
+ error:
24
+ "This conversation is already helping another customer, and you can help only " +
25
+ "one customer per conversation. Ask the caller to start a new call.",
26
+ };
27
+ }
28
+ state.authenticatedUserId = user.user_id;
29
+ return {
30
+ user_id: user.user_id,
31
+ name: `${user.name.first_name} ${user.name.last_name}`,
32
+ order_count: user.orders.length,
33
+ };
34
+ }
@@ -0,0 +1,459 @@
1
+ import "@alexkroman1/aai-ui/styles.css";
2
+ import type { ChatMessage } from "@alexkroman1/aai-ui";
3
+ import { client, useAgentState, useSession } from "@alexkroman1/aai-ui";
4
+ import type { ReactNode } from "react";
5
+ import { useEffect, useRef } from "react";
6
+ import type {
7
+ OrderStatus,
8
+ OrderView,
9
+ PaymentMethodView,
10
+ StoreView,
11
+ SwapOptionView,
12
+ } from "./shared.ts";
13
+ import { DEMO_PERSONAS, storeView } from "./shared.ts";
14
+
15
+ const CSS = `
16
+ @keyframes rt-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } }
17
+ @keyframes rt-slide-in { from { transform: translateY(8px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
18
+ .rt-scroll::-webkit-scrollbar { width: 6px; }
19
+ .rt-scroll::-webkit-scrollbar-track { background: transparent; }
20
+ .rt-scroll::-webkit-scrollbar-thumb { background: #d4d4d8; border-radius: 3px; }
21
+ @media (max-width: 900px) {
22
+ /* Both rows bounded: an auto row sizes to the sidebar's full content, which
23
+ would push the chat (and its Hold/End controls) past the viewport. */
24
+ .rt-main { grid-template-columns: 1fr !important; grid-template-rows: minmax(0, 1fr) minmax(0, 40%) !important; }
25
+ }
26
+ `;
27
+
28
+ // `satisfies`-pinned to the shared union, so a new order status is a compile
29
+ // error here rather than a silently grey badge.
30
+ const statusColors: Record<string, string> = {
31
+ pending: "#b45309",
32
+ "pending (item modified)": "#7c3aed",
33
+ processed: "#0369a1",
34
+ delivered: "#15803d",
35
+ cancelled: "#71717a",
36
+ "return requested": "#c2410c",
37
+ "exchange requested": "#a16207",
38
+ } satisfies Record<OrderStatus, string>;
39
+
40
+ // The sidebar before the first tool call, derived from the projection itself so
41
+ // a new StoreView field can't miss the pre-first-call render.
42
+ const EMPTY_VIEW: StoreView = storeView({});
43
+
44
+ function stateColor(state: string): string {
45
+ if (state === "listening" || state === "ready") return "#16a34a";
46
+ if (state === "thinking") return "#ca8a04";
47
+ if (state === "speaking") return "#2563eb";
48
+ return "#a1a1aa";
49
+ }
50
+
51
+ function Panel({ title, children }: { title: string; children: ReactNode }) {
52
+ return (
53
+ <div className="rounded-lg p-3" style={{ background: "#ffffff", border: "1px solid #e4e4e7" }}>
54
+ <div
55
+ className="text-[10px] font-semibold uppercase tracking-[1.3px] mb-2"
56
+ style={{ color: "#71717a" }}
57
+ >
58
+ {title}
59
+ </div>
60
+ {children}
61
+ </div>
62
+ );
63
+ }
64
+
65
+ function MethodRow({ method }: { method: PaymentMethodView }) {
66
+ return (
67
+ <div className="flex justify-between items-center py-0.5 text-xs">
68
+ <span style={{ color: "#52525b" }}>{method.label}</span>
69
+ {method.balance === undefined ? (
70
+ <span className="text-[10px] uppercase tracking-wider" style={{ color: "#a1a1aa" }}>
71
+ on file
72
+ </span>
73
+ ) : (
74
+ <span className="font-semibold tabular-nums" style={{ color: "#15803d" }}>
75
+ ${method.balance.toFixed(2)}
76
+ </span>
77
+ )}
78
+ </div>
79
+ );
80
+ }
81
+
82
+ function OrderCard({ order, focused }: { order: OrderView; focused: boolean }) {
83
+ const color = statusColors[order.status] ?? "#71717a";
84
+ return (
85
+ <div
86
+ className="rounded-md p-2.5 mb-2"
87
+ style={{
88
+ background: focused ? "#fafafa" : "#ffffff",
89
+ animation: "rt-slide-in 0.25s ease-out",
90
+ border: `1px solid ${focused ? color : "#e4e4e7"}`,
91
+ borderLeft: `3px solid ${color}`,
92
+ boxShadow: focused ? `0 0 0 2px ${color}22` : "none",
93
+ }}
94
+ >
95
+ <div className="flex justify-between items-center gap-2 mb-1">
96
+ <span className="text-xs font-semibold tabular-nums" style={{ color: "#18181b" }}>
97
+ {order.orderId}
98
+ </span>
99
+ <span
100
+ className="text-[9px] px-1.5 py-0.5 rounded font-semibold uppercase whitespace-nowrap"
101
+ style={{ background: `${color}1a`, color }}
102
+ >
103
+ {order.status}
104
+ </span>
105
+ </div>
106
+ <div className="text-[11px] mb-1 tabular-nums" style={{ color: "#71717a" }}>
107
+ ${order.total.toFixed(2)} · {order.items.length} item
108
+ {order.items.length === 1 ? "" : "s"}
109
+ </div>
110
+ {order.items.slice(0, 3).map((item) => (
111
+ <div
112
+ key={`${item.itemId}-${item.name}`}
113
+ className="text-[11px]"
114
+ style={{ color: "#52525b" }}
115
+ >
116
+ · {item.name}{" "}
117
+ <span style={{ color: "#a1a1aa" }}>{Object.values(item.options).join(", ")}</span>
118
+ </div>
119
+ ))}
120
+ {order.items.length > 3 && (
121
+ <div className="text-[11px]" style={{ color: "#a1a1aa" }}>
122
+ · and {order.items.length - 3} more
123
+ </div>
124
+ )}
125
+ </div>
126
+ );
127
+ }
128
+
129
+ /** Character select. Rendered before authentication, because a user with no
130
+ * email to give cannot start the conversation at all. */
131
+ function PersonaList() {
132
+ return (
133
+ <div className="flex flex-col gap-2">
134
+ <div className="text-[11px] leading-snug" style={{ color: "#3f3f46" }}>
135
+ Pick someone to be, then read their email to the agent.
136
+ </div>
137
+ {DEMO_PERSONAS.map((persona) => (
138
+ <div
139
+ key={persona.email}
140
+ className="rounded-md p-2"
141
+ style={{ background: "#ffffff", border: "1px solid #e4e4e7" }}
142
+ >
143
+ <div className="text-xs font-semibold" style={{ color: "#18181b" }}>
144
+ {persona.name}
145
+ </div>
146
+ <div className="text-[11px] break-all" style={{ color: "#2563eb" }}>
147
+ {persona.email}
148
+ </div>
149
+ <div className="text-[10px] tabular-nums" style={{ color: "#a1a1aa" }}>
150
+ zip {persona.zip}
151
+ </div>
152
+ <div className="text-[10px] leading-snug mt-1" style={{ color: "#71717a" }}>
153
+ {persona.hint}
154
+ </div>
155
+ </div>
156
+ ))}
157
+ </div>
158
+ );
159
+ }
160
+
161
+ /** What the focused order's items could become — the words a user needs in order
162
+ * to ask for an exchange or an item change. */
163
+ function SwapOptions({ option }: { option: SwapOptionView }) {
164
+ return (
165
+ <div className="mb-2.5">
166
+ <div className="text-[11px] font-semibold" style={{ color: "#18181b" }}>
167
+ {option.itemName}
168
+ </div>
169
+ <div className="text-[10px] mb-1" style={{ color: "#a1a1aa" }}>
170
+ now: {Object.values(option.currentOptions).join(", ")}
171
+ </div>
172
+ {option.alternatives.length === 0 ? (
173
+ <div className="text-[10px]" style={{ color: "#a1a1aa" }}>
174
+ No other option is in stock.
175
+ </div>
176
+ ) : (
177
+ option.alternatives.map((alternative) => (
178
+ <div
179
+ key={alternative.itemId}
180
+ className="flex justify-between gap-2 text-[10px] py-0.5"
181
+ style={{ color: "#52525b" }}
182
+ >
183
+ <span>{Object.values(alternative.options).join(", ")}</span>
184
+ <span className="tabular-nums whitespace-nowrap" style={{ color: "#71717a" }}>
185
+ ${alternative.price.toFixed(2)}
186
+ </span>
187
+ </div>
188
+ ))
189
+ )}
190
+ </div>
191
+ );
192
+ }
193
+
194
+ function App() {
195
+ const session = useSession();
196
+ const messagesEndRef = useRef<HTMLDivElement>(null);
197
+ // The agent's own store, projected by `syncState` after every tool call.
198
+ const view = useAgentState<StoreView>() ?? EMPTY_VIEW;
199
+
200
+ useEffect(() => {
201
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
202
+ }, [session.messages]);
203
+
204
+ const lastAction = view.activity.at(-1);
205
+
206
+ return (
207
+ <>
208
+ <style>{CSS}</style>
209
+ <div
210
+ className="flex flex-col h-dvh overflow-hidden m-0 p-0"
211
+ style={{ background: "#f4f4f5", color: "#18181b" }}
212
+ >
213
+ <div
214
+ className="flex items-center justify-between px-5 py-3 gap-4 flex-wrap shrink-0"
215
+ style={{ background: "#ffffff", borderBottom: "1px solid #e4e4e7" }}
216
+ >
217
+ <div className="flex items-center gap-2.5 text-base font-semibold">
218
+ <span style={{ color: "#2563eb" }}>◆</span>
219
+ Retail Support
220
+ <span
221
+ className="w-2 h-2 rounded-full inline-block"
222
+ style={{
223
+ background: stateColor(session.state),
224
+ animation:
225
+ session.state === "listening"
226
+ ? "rt-pulse 1.5s ease-in-out infinite"
227
+ : session.state === "thinking"
228
+ ? "rt-pulse 0.8s ease-in-out infinite"
229
+ : "none",
230
+ }}
231
+ title={session.state}
232
+ />
233
+ <span className="text-[11px] font-normal" style={{ color: "#a1a1aa" }}>
234
+ {session.state}
235
+ </span>
236
+ </div>
237
+ <span
238
+ className="px-2.5 py-1 rounded text-[10px] font-semibold uppercase tracking-wider"
239
+ style={
240
+ view.customer
241
+ ? { background: "#dcfce7", color: "#15803d" }
242
+ : { background: "#f4f4f5", color: "#71717a" }
243
+ }
244
+ >
245
+ {view.customer ? `Verified · ${view.customer.name}` : "Not yet identified"}
246
+ </span>
247
+ </div>
248
+
249
+ <div
250
+ className="rt-main flex-1 grid overflow-hidden"
251
+ style={{ gridTemplateColumns: "1fr 340px" }}
252
+ >
253
+ {/* Conversation */}
254
+ <div
255
+ className="flex flex-col overflow-hidden"
256
+ style={{ borderRight: "1px solid #e4e4e7" }}
257
+ >
258
+ <div className="rt-scroll flex-1 overflow-y-auto p-4 flex flex-col gap-2">
259
+ {session.messages.length === 0 && (
260
+ <div className="text-center p-10 text-[13px]" style={{ color: "#a1a1aa" }}>
261
+ Press start, then read one of the emails from “Who to be” to the agent.
262
+ </div>
263
+ )}
264
+ {session.messages.map((message: ChatMessage, index: number) => (
265
+ <div
266
+ key={index}
267
+ className="rounded-lg text-[13px] max-w-[80%] px-3.5 py-2.5"
268
+ style={{
269
+ lineHeight: 1.55,
270
+ alignSelf: message.role === "assistant" ? "flex-start" : "flex-end",
271
+ background: message.role === "assistant" ? "#ffffff" : "#2563eb",
272
+ color: message.role === "assistant" ? "#18181b" : "#ffffff",
273
+ border: message.role === "assistant" ? "1px solid #e4e4e7" : "none",
274
+ animation: "rt-slide-in 0.2s ease-out",
275
+ }}
276
+ >
277
+ {message.content}
278
+ </div>
279
+ ))}
280
+ <div ref={messagesEndRef} />
281
+ </div>
282
+
283
+ {session.userTranscript !== null && (
284
+ <div
285
+ className="flex items-center px-4 py-2 text-xs italic min-h-8"
286
+ style={{ background: "#fafafa", borderTop: "1px solid #e4e4e7", color: "#71717a" }}
287
+ >
288
+ <span
289
+ className="w-2 h-2 rounded-full inline-block mr-2"
290
+ style={{ background: "#16a34a", animation: "rt-pulse 1.5s ease-in-out infinite" }}
291
+ />
292
+ {session.userTranscript === "" ? "…" : session.userTranscript}
293
+ </div>
294
+ )}
295
+ {session.error && (
296
+ <div
297
+ className="px-4 py-2 text-xs"
298
+ style={{ background: "#fef2f2", color: "#b91c1c", borderTop: "1px solid #fecaca" }}
299
+ >
300
+ {session.error.message} ({session.error.code})
301
+ </div>
302
+ )}
303
+
304
+ <div
305
+ className="flex items-center gap-2 px-4 py-3"
306
+ style={{ background: "#ffffff", borderTop: "1px solid #e4e4e7" }}
307
+ >
308
+ {!session.started ? (
309
+ <button
310
+ type="button"
311
+ className="px-4 py-2 border-none rounded-md text-xs font-semibold cursor-pointer text-white"
312
+ style={{ background: "#2563eb" }}
313
+ onClick={() => session.start()}
314
+ >
315
+ Start call
316
+ </button>
317
+ ) : (
318
+ <>
319
+ <button
320
+ type="button"
321
+ className="px-4 py-2 border-none rounded-md text-xs font-semibold cursor-pointer"
322
+ style={{
323
+ background: session.running ? "#e4e4e7" : "#2563eb",
324
+ color: session.running ? "#18181b" : "#ffffff",
325
+ }}
326
+ onClick={() => session.toggle()}
327
+ >
328
+ {session.running ? "Hold" : "Resume"}
329
+ </button>
330
+ {/* end() hangs up and flips `started` back, so the UI
331
+ returns to "Start call" and the next start is a brand-new
332
+ session (fresh store, greeting included). reset() would
333
+ keep the call live — the buttons never toggle back. */}
334
+ <button
335
+ type="button"
336
+ className="px-4 py-2 border-none rounded-md text-xs font-semibold cursor-pointer text-white"
337
+ style={{ background: "#dc2626" }}
338
+ onClick={() => session.end()}
339
+ >
340
+ End
341
+ </button>
342
+ </>
343
+ )}
344
+ <div className="flex-1" />
345
+ <span className="text-[10px] tabular-nums" style={{ color: "#a1a1aa" }}>
346
+ {view.productCount} products
347
+ </span>
348
+ </div>
349
+ </div>
350
+
351
+ {/* Sidebar: the customer file */}
352
+ <div
353
+ className="rt-scroll overflow-y-auto p-3 flex flex-col gap-3"
354
+ style={{ background: "#f4f4f5" }}
355
+ >
356
+ {!view.customer && (
357
+ <Panel title="Who to be">
358
+ <PersonaList />
359
+ </Panel>
360
+ )}
361
+
362
+ <Panel title="Customer">
363
+ {view.customer ? (
364
+ <>
365
+ <div className="text-sm font-semibold">{view.customer.name}</div>
366
+ <div className="text-[11px] break-all" style={{ color: "#71717a" }}>
367
+ {view.customer.email}
368
+ </div>
369
+ <div className="text-[11px] mt-1" style={{ color: "#71717a" }}>
370
+ {view.customer.address.address1}
371
+ {view.customer.address.address2 ? `, ${view.customer.address.address2}` : ""}
372
+ <br />
373
+ {view.customer.address.city} {view.customer.address.state}{" "}
374
+ {view.customer.address.zip}
375
+ </div>
376
+ </>
377
+ ) : (
378
+ <div className="text-xs" style={{ color: "#a1a1aa" }}>
379
+ Nothing is looked up until the caller is identified.
380
+ </div>
381
+ )}
382
+ </Panel>
383
+
384
+ {view.customer && (
385
+ <Panel title="Payment methods">
386
+ {view.customer.paymentMethods.map((method) => (
387
+ <MethodRow key={method.id} method={method} />
388
+ ))}
389
+ </Panel>
390
+ )}
391
+
392
+ {view.orders.length > 0 && (
393
+ <Panel title={`Orders (${view.orders.length})`}>
394
+ {view.orders.map((order) => (
395
+ <OrderCard
396
+ key={order.orderId}
397
+ order={order}
398
+ focused={order.orderId === view.focus.orderId}
399
+ />
400
+ ))}
401
+ </Panel>
402
+ )}
403
+
404
+ {view.swapOptions.length > 0 && (
405
+ <Panel title={`Swap options · ${view.focus.orderId ?? ""}`}>
406
+ {view.swapOptions.map((option) => (
407
+ <SwapOptions key={option.itemId} option={option} />
408
+ ))}
409
+ </Panel>
410
+ )}
411
+
412
+ <Panel title="You can say">
413
+ {view.scriptBullets.map((bullet) => (
414
+ <div
415
+ key={bullet}
416
+ className="text-[11px] leading-relaxed mb-1"
417
+ style={{ color: "#3f3f46" }}
418
+ >
419
+ · {bullet}
420
+ </div>
421
+ ))}
422
+ </Panel>
423
+ </div>
424
+ </div>
425
+
426
+ {/* The footer strip. `callSeq` is monotonic, so this moves on EVERY tool
427
+ call — including a repeated read that changed nothing else. */}
428
+ <div
429
+ className="flex items-center justify-between px-5 py-2 text-[10px] shrink-0"
430
+ style={{ background: "#ffffff", borderTop: "1px solid #e4e4e7", color: "#71717a" }}
431
+ >
432
+ <span className="truncate">
433
+ {lastAction ? (
434
+ <>
435
+ <span style={{ color: "#2563eb" }}>{lastAction.tool}</span> — {lastAction.summary}
436
+ </>
437
+ ) : (
438
+ "No tool calls yet"
439
+ )}
440
+ </span>
441
+ <span className="tabular-nums whitespace-nowrap ml-3">
442
+ {view.callSeq} call{view.callSeq === 1 ? "" : "s"}
443
+ </span>
444
+ </div>
445
+ </div>
446
+ </>
447
+ );
448
+ }
449
+
450
+ client({
451
+ component: App,
452
+ theme: {
453
+ bg: "#f4f4f5",
454
+ primary: "#2563eb",
455
+ text: "#18181b",
456
+ surface: "#ffffff",
457
+ border: "#e4e4e7",
458
+ },
459
+ });
@@ -0,0 +1,19 @@
1
+ import type { User } from "./shared.ts";
2
+ import { isGiftCard, money } from "./store.ts";
3
+
4
+ /**
5
+ * Credit a refund. A gift card is topped up in place and the money is back
6
+ * immediately; every other method takes 5–7 business days, which is a statement
7
+ * about the outside world and so changes no state here.
8
+ */
9
+ export function creditRefund(user: User, methodId: string, amount: number): { immediate: boolean } {
10
+ const method = user.payment_methods[methodId];
11
+ if (method && isGiftCard(method)) {
12
+ method.balance = money(method.balance + amount);
13
+ return { immediate: true };
14
+ }
15
+ return { immediate: false };
16
+ }
17
+
18
+ export const REFUND_DELAY_NOTE = "The refund will appear in 5 to 7 business days.";
19
+ export const REFUND_IMMEDIATE_NOTE = "The refund is on the gift card immediately.";