@open-mercato/ui 0.5.1-develop.3036.f02c281f23 → 0.5.1-develop.3045.b4b3320cc2

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 (148) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -1
  3. package/__integration__/TC-AI-UI-003-aichat-registry.spec.tsx +204 -0
  4. package/dist/ai/AiAssistantLauncher.js +596 -0
  5. package/dist/ai/AiAssistantLauncher.js.map +7 -0
  6. package/dist/ai/AiChat.js +1092 -0
  7. package/dist/ai/AiChat.js.map +7 -0
  8. package/dist/ai/AiChatSessions.js +297 -0
  9. package/dist/ai/AiChatSessions.js.map +7 -0
  10. package/dist/ai/AiDock.js +347 -0
  11. package/dist/ai/AiDock.js.map +7 -0
  12. package/dist/ai/AiMessageContent.js +369 -0
  13. package/dist/ai/AiMessageContent.js.map +7 -0
  14. package/dist/ai/ChatPaneTabs.js +251 -0
  15. package/dist/ai/ChatPaneTabs.js.map +7 -0
  16. package/dist/ai/index.js +115 -0
  17. package/dist/ai/index.js.map +7 -0
  18. package/dist/ai/parts/ConfirmationCard.js +211 -0
  19. package/dist/ai/parts/ConfirmationCard.js.map +7 -0
  20. package/dist/ai/parts/FieldDiffCard.js +119 -0
  21. package/dist/ai/parts/FieldDiffCard.js.map +7 -0
  22. package/dist/ai/parts/MutationPreviewCard.js +224 -0
  23. package/dist/ai/parts/MutationPreviewCard.js.map +7 -0
  24. package/dist/ai/parts/MutationResultCard.js +240 -0
  25. package/dist/ai/parts/MutationResultCard.js.map +7 -0
  26. package/dist/ai/parts/approval-cards-map.js +15 -0
  27. package/dist/ai/parts/approval-cards-map.js.map +7 -0
  28. package/dist/ai/parts/index.js +24 -0
  29. package/dist/ai/parts/index.js.map +7 -0
  30. package/dist/ai/parts/pending-action-api.js +60 -0
  31. package/dist/ai/parts/pending-action-api.js.map +7 -0
  32. package/dist/ai/parts/types.js +1 -0
  33. package/dist/ai/parts/types.js.map +7 -0
  34. package/dist/ai/parts/useAiPendingActionPolling.js +126 -0
  35. package/dist/ai/parts/useAiPendingActionPolling.js.map +7 -0
  36. package/dist/ai/records/ActivityCard.js +83 -0
  37. package/dist/ai/records/ActivityCard.js.map +7 -0
  38. package/dist/ai/records/CompanyCard.js +81 -0
  39. package/dist/ai/records/CompanyCard.js.map +7 -0
  40. package/dist/ai/records/DealCard.js +76 -0
  41. package/dist/ai/records/DealCard.js.map +7 -0
  42. package/dist/ai/records/PersonCard.js +68 -0
  43. package/dist/ai/records/PersonCard.js.map +7 -0
  44. package/dist/ai/records/ProductCard.js +68 -0
  45. package/dist/ai/records/ProductCard.js.map +7 -0
  46. package/dist/ai/records/RecordCard.js +29 -0
  47. package/dist/ai/records/RecordCard.js.map +7 -0
  48. package/dist/ai/records/RecordCardShell.js +103 -0
  49. package/dist/ai/records/RecordCardShell.js.map +7 -0
  50. package/dist/ai/records/index.js +31 -0
  51. package/dist/ai/records/index.js.map +7 -0
  52. package/dist/ai/records/registry.js +51 -0
  53. package/dist/ai/records/registry.js.map +7 -0
  54. package/dist/ai/records/types.js +1 -0
  55. package/dist/ai/records/types.js.map +7 -0
  56. package/dist/ai/ui-part-registry.js +112 -0
  57. package/dist/ai/ui-part-registry.js.map +7 -0
  58. package/dist/ai/ui-part-slots.js +14 -0
  59. package/dist/ai/ui-part-slots.js.map +7 -0
  60. package/dist/ai/ui-parts/pending-phase3-placeholder.js +35 -0
  61. package/dist/ai/ui-parts/pending-phase3-placeholder.js.map +7 -0
  62. package/dist/ai/upload-adapter.js +256 -0
  63. package/dist/ai/upload-adapter.js.map +7 -0
  64. package/dist/ai/useAiChat.js +549 -0
  65. package/dist/ai/useAiChat.js.map +7 -0
  66. package/dist/ai/useAiChatUpload.js +127 -0
  67. package/dist/ai/useAiChatUpload.js.map +7 -0
  68. package/dist/ai/useAiShortcuts.js +43 -0
  69. package/dist/ai/useAiShortcuts.js.map +7 -0
  70. package/dist/backend/AppShell.js +8 -4
  71. package/dist/backend/AppShell.js.map +2 -2
  72. package/dist/backend/BackendChromeProvider.js +2 -0
  73. package/dist/backend/BackendChromeProvider.js.map +2 -2
  74. package/dist/backend/DataTable.js +19 -2
  75. package/dist/backend/DataTable.js.map +2 -2
  76. package/dist/backend/FilterBar.js +19 -15
  77. package/dist/backend/FilterBar.js.map +2 -2
  78. package/dist/backend/dashboard/DashboardScreen.js +31 -3
  79. package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
  80. package/dist/backend/injection/spotIds.js +6 -0
  81. package/dist/backend/injection/spotIds.js.map +2 -2
  82. package/dist/backend/notifications/useNotificationEffect.js +38 -2
  83. package/dist/backend/notifications/useNotificationEffect.js.map +2 -2
  84. package/dist/index.js +1 -0
  85. package/dist/index.js.map +2 -2
  86. package/jest.config.cjs +7 -1
  87. package/jest.markdown-mock.tsx +7 -0
  88. package/package.json +10 -4
  89. package/src/ai/AiAssistantLauncher.tsx +805 -0
  90. package/src/ai/AiChat.tsx +1483 -0
  91. package/src/ai/AiChatSessions.tsx +429 -0
  92. package/src/ai/AiDock.tsx +505 -0
  93. package/src/ai/AiMessageContent.tsx +515 -0
  94. package/src/ai/ChatPaneTabs.tsx +310 -0
  95. package/src/ai/__tests__/AiChat.conversation.test.tsx +160 -0
  96. package/src/ai/__tests__/AiChat.debug.test.tsx +152 -0
  97. package/src/ai/__tests__/AiChat.registry.test.tsx +213 -0
  98. package/src/ai/__tests__/AiChat.test.tsx +257 -0
  99. package/src/ai/__tests__/AiDock.test.tsx +124 -0
  100. package/src/ai/__tests__/AiMessageContent.test.ts +111 -0
  101. package/src/ai/__tests__/ui-part-registry.test.ts +199 -0
  102. package/src/ai/__tests__/ui-part-slots.test.ts +43 -0
  103. package/src/ai/__tests__/upload-adapter.test.ts +213 -0
  104. package/src/ai/__tests__/useAiChatUpload.test.tsx +163 -0
  105. package/src/ai/__tests__/useAiShortcuts.test.tsx +100 -0
  106. package/src/ai/index.ts +125 -0
  107. package/src/ai/parts/ConfirmationCard.tsx +310 -0
  108. package/src/ai/parts/FieldDiffCard.tsx +173 -0
  109. package/src/ai/parts/MutationPreviewCard.tsx +302 -0
  110. package/src/ai/parts/MutationResultCard.tsx +360 -0
  111. package/src/ai/parts/__tests__/ConfirmationCard.test.tsx +169 -0
  112. package/src/ai/parts/__tests__/FieldDiffCard.test.tsx +74 -0
  113. package/src/ai/parts/__tests__/MutationPreviewCard.test.tsx +177 -0
  114. package/src/ai/parts/__tests__/MutationResultCard.test.tsx +127 -0
  115. package/src/ai/parts/__tests__/useAiPendingActionPolling.test.tsx +151 -0
  116. package/src/ai/parts/approval-cards-map.ts +24 -0
  117. package/src/ai/parts/index.ts +27 -0
  118. package/src/ai/parts/pending-action-api.ts +123 -0
  119. package/src/ai/parts/types.ts +84 -0
  120. package/src/ai/parts/useAiPendingActionPolling.ts +210 -0
  121. package/src/ai/records/ActivityCard.tsx +102 -0
  122. package/src/ai/records/CompanyCard.tsx +89 -0
  123. package/src/ai/records/DealCard.tsx +85 -0
  124. package/src/ai/records/PersonCard.tsx +77 -0
  125. package/src/ai/records/ProductCard.tsx +83 -0
  126. package/src/ai/records/RecordCard.tsx +37 -0
  127. package/src/ai/records/RecordCardShell.tsx +169 -0
  128. package/src/ai/records/index.ts +30 -0
  129. package/src/ai/records/registry.tsx +80 -0
  130. package/src/ai/records/types.ts +90 -0
  131. package/src/ai/ui-part-registry.ts +233 -0
  132. package/src/ai/ui-part-slots.ts +32 -0
  133. package/src/ai/ui-parts/pending-phase3-placeholder.tsx +50 -0
  134. package/src/ai/upload-adapter.ts +421 -0
  135. package/src/ai/useAiChat.ts +865 -0
  136. package/src/ai/useAiChatUpload.ts +180 -0
  137. package/src/ai/useAiShortcuts.ts +79 -0
  138. package/src/backend/AppShell.tsx +12 -5
  139. package/src/backend/BackendChromeProvider.tsx +2 -0
  140. package/src/backend/DataTable.tsx +20 -1
  141. package/src/backend/FilterBar.tsx +26 -13
  142. package/src/backend/__tests__/BackendChromeProvider.test.tsx +45 -0
  143. package/src/backend/dashboard/DashboardScreen.tsx +38 -3
  144. package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +24 -1
  145. package/src/backend/injection/spotIds.ts +6 -0
  146. package/src/backend/notifications/__tests__/useNotificationEffect.test.tsx +77 -0
  147. package/src/backend/notifications/useNotificationEffect.ts +47 -2
  148. package/src/index.ts +1 -0
@@ -0,0 +1,515 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { MarkdownContent } from '../backend/markdown/MarkdownContent'
5
+ import { RecordCard } from './records/RecordCard'
6
+ import type {
7
+ ActivityRecordPayload,
8
+ CompanyRecordPayload,
9
+ DealRecordPayload,
10
+ PersonRecordPayload,
11
+ ProductRecordPayload,
12
+ RecordCardKind,
13
+ RecordCardPayload,
14
+ } from './records/types'
15
+
16
+ /**
17
+ * Info-string prefix recognised inside fenced code blocks. Anything matching
18
+ * ```open-mercato:<kind>``` is parsed as JSON and rendered via the matching
19
+ * record card. Unknown kinds fall back to a plain `<code>` block so the
20
+ * transcript never crashes on a malformed widget payload.
21
+ */
22
+ export const RECORD_CARD_FENCE_INFO_PREFIX = 'open-mercato:'
23
+
24
+ const KNOWN_KINDS: ReadonlySet<RecordCardKind> = new Set([
25
+ 'deal',
26
+ 'person',
27
+ 'company',
28
+ 'product',
29
+ 'activity',
30
+ ])
31
+
32
+ export type AiMessageContentSegment =
33
+ | { kind: 'markdown'; text: string }
34
+ | { kind: 'record-card'; payload: RecordCardPayload; raw: string }
35
+ | { kind: 'invalid-card'; info: string; raw: string }
36
+
37
+ interface FenceMatch {
38
+ start: number
39
+ end: number
40
+ info: string
41
+ body: string
42
+ closed: boolean
43
+ }
44
+
45
+ const FENCE_RE = /```([^\n`]*)\n([\s\S]*?)(```|$)/g
46
+
47
+ function findFences(input: string): FenceMatch[] {
48
+ const matches: FenceMatch[] = []
49
+ FENCE_RE.lastIndex = 0
50
+ for (;;) {
51
+ const match = FENCE_RE.exec(input)
52
+ if (!match) break
53
+ matches.push({
54
+ start: match.index,
55
+ end: match.index + match[0].length,
56
+ info: (match[1] ?? '').trim(),
57
+ body: match[2] ?? '',
58
+ closed: match[3] === '```',
59
+ })
60
+ if (match[0].length === 0) {
61
+ FENCE_RE.lastIndex += 1
62
+ }
63
+ }
64
+ return matches
65
+ }
66
+
67
+ function coerceKind(value: unknown): RecordCardKind | null {
68
+ if (typeof value !== 'string') return null
69
+ const normalized = value.toLowerCase().trim()
70
+ return KNOWN_KINDS.has(normalized as RecordCardKind)
71
+ ? (normalized as RecordCardKind)
72
+ : null
73
+ }
74
+
75
+ function normalizeStringList(value: unknown): string[] | null {
76
+ if (!Array.isArray(value)) return null
77
+ const out: string[] = []
78
+ for (const entry of value) {
79
+ if (typeof entry === 'string' && entry.trim()) {
80
+ out.push(entry.trim())
81
+ } else if (entry && typeof entry === 'object') {
82
+ const maybeLabel = (entry as { label?: unknown; name?: unknown }).label
83
+ const maybeName = (entry as { name?: unknown }).name
84
+ if (typeof maybeLabel === 'string') out.push(maybeLabel)
85
+ else if (typeof maybeName === 'string') out.push(maybeName)
86
+ }
87
+ }
88
+ return out
89
+ }
90
+
91
+ function normalizeRecordPayload(
92
+ kind: RecordCardKind,
93
+ raw: Record<string, unknown>,
94
+ ): RecordCardPayload | null {
95
+ const id = typeof raw.id === 'string' ? raw.id : undefined
96
+ const href = typeof raw.href === 'string' ? raw.href : undefined
97
+ const tags = normalizeStringList(raw.tags) ?? null
98
+ const status = typeof raw.status === 'string' ? raw.status : null
99
+
100
+ if (kind === 'deal') {
101
+ const title =
102
+ (typeof raw.title === 'string' && raw.title) ||
103
+ (typeof raw.name === 'string' && raw.name) ||
104
+ null
105
+ if (!title) return null
106
+ const payload: DealRecordPayload = {
107
+ id,
108
+ href,
109
+ title,
110
+ status,
111
+ stage: typeof raw.stage === 'string' ? raw.stage : null,
112
+ amount:
113
+ typeof raw.amount === 'number' || typeof raw.amount === 'string'
114
+ ? raw.amount
115
+ : null,
116
+ currency: typeof raw.currency === 'string' ? raw.currency : null,
117
+ closeDate:
118
+ typeof raw.closeDate === 'string'
119
+ ? raw.closeDate
120
+ : typeof (raw as { close_date?: unknown }).close_date === 'string'
121
+ ? (raw as { close_date: string }).close_date
122
+ : null,
123
+ ownerName:
124
+ typeof raw.ownerName === 'string'
125
+ ? raw.ownerName
126
+ : typeof (raw as { owner?: unknown }).owner === 'string'
127
+ ? (raw as { owner: string }).owner
128
+ : null,
129
+ personName: typeof raw.personName === 'string' ? raw.personName : null,
130
+ companyName: typeof raw.companyName === 'string' ? raw.companyName : null,
131
+ description: typeof raw.description === 'string' ? raw.description : null,
132
+ tags,
133
+ }
134
+ return { kind: 'deal', ...payload }
135
+ }
136
+
137
+ if (kind === 'person') {
138
+ const name =
139
+ (typeof raw.name === 'string' && raw.name) ||
140
+ [
141
+ typeof raw.firstName === 'string' ? raw.firstName : '',
142
+ typeof raw.lastName === 'string' ? raw.lastName : '',
143
+ ]
144
+ .filter(Boolean)
145
+ .join(' ')
146
+ .trim() ||
147
+ null
148
+ if (!name) return null
149
+ const payload: PersonRecordPayload = {
150
+ id,
151
+ href,
152
+ name,
153
+ title: typeof raw.title === 'string' ? raw.title : null,
154
+ email: typeof raw.email === 'string' ? raw.email : null,
155
+ phone: typeof raw.phone === 'string' ? raw.phone : null,
156
+ companyName: typeof raw.companyName === 'string' ? raw.companyName : null,
157
+ ownerName: typeof raw.ownerName === 'string' ? raw.ownerName : null,
158
+ status,
159
+ tags,
160
+ avatarUrl:
161
+ typeof raw.avatarUrl === 'string'
162
+ ? raw.avatarUrl
163
+ : typeof (raw as { avatar?: unknown }).avatar === 'string'
164
+ ? (raw as { avatar: string }).avatar
165
+ : null,
166
+ }
167
+ return { kind: 'person', ...payload }
168
+ }
169
+
170
+ if (kind === 'company') {
171
+ const name = typeof raw.name === 'string' ? raw.name : null
172
+ if (!name) return null
173
+ const payload: CompanyRecordPayload = {
174
+ id,
175
+ href,
176
+ name,
177
+ industry: typeof raw.industry === 'string' ? raw.industry : null,
178
+ website: typeof raw.website === 'string' ? raw.website : null,
179
+ email: typeof raw.email === 'string' ? raw.email : null,
180
+ phone: typeof raw.phone === 'string' ? raw.phone : null,
181
+ city: typeof raw.city === 'string' ? raw.city : null,
182
+ country: typeof raw.country === 'string' ? raw.country : null,
183
+ ownerName: typeof raw.ownerName === 'string' ? raw.ownerName : null,
184
+ status,
185
+ tags,
186
+ logoUrl:
187
+ typeof raw.logoUrl === 'string'
188
+ ? raw.logoUrl
189
+ : typeof (raw as { logo?: unknown }).logo === 'string'
190
+ ? (raw as { logo: string }).logo
191
+ : null,
192
+ }
193
+ return { kind: 'company', ...payload }
194
+ }
195
+
196
+ if (kind === 'product') {
197
+ const name =
198
+ (typeof raw.name === 'string' && raw.name) ||
199
+ (typeof raw.title === 'string' && raw.title) ||
200
+ null
201
+ if (!name) return null
202
+ const payload: ProductRecordPayload = {
203
+ id,
204
+ href,
205
+ name,
206
+ sku: typeof raw.sku === 'string' ? raw.sku : null,
207
+ price:
208
+ typeof raw.price === 'number' || typeof raw.price === 'string'
209
+ ? raw.price
210
+ : null,
211
+ currency: typeof raw.currency === 'string' ? raw.currency : null,
212
+ status,
213
+ category: typeof raw.category === 'string' ? raw.category : null,
214
+ description: typeof raw.description === 'string' ? raw.description : null,
215
+ imageUrl:
216
+ typeof raw.imageUrl === 'string'
217
+ ? raw.imageUrl
218
+ : typeof (raw as { image?: unknown }).image === 'string'
219
+ ? (raw as { image: string }).image
220
+ : null,
221
+ tags,
222
+ }
223
+ return { kind: 'product', ...payload }
224
+ }
225
+
226
+ if (kind === 'activity') {
227
+ const title =
228
+ (typeof raw.title === 'string' && raw.title) ||
229
+ (typeof raw.subject === 'string' && raw.subject) ||
230
+ null
231
+ if (!title) return null
232
+ const payload: ActivityRecordPayload = {
233
+ id,
234
+ href,
235
+ title,
236
+ type: typeof raw.type === 'string' ? raw.type : null,
237
+ status,
238
+ dueDate:
239
+ typeof raw.dueDate === 'string'
240
+ ? raw.dueDate
241
+ : typeof (raw as { due_at?: unknown }).due_at === 'string'
242
+ ? (raw as { due_at: string }).due_at
243
+ : null,
244
+ completedAt:
245
+ typeof raw.completedAt === 'string'
246
+ ? raw.completedAt
247
+ : typeof (raw as { completed_at?: unknown }).completed_at === 'string'
248
+ ? (raw as { completed_at: string }).completed_at
249
+ : null,
250
+ ownerName: typeof raw.ownerName === 'string' ? raw.ownerName : null,
251
+ relatedTo:
252
+ typeof raw.relatedTo === 'string'
253
+ ? raw.relatedTo
254
+ : typeof (raw as { related?: unknown }).related === 'string'
255
+ ? (raw as { related: string }).related
256
+ : null,
257
+ description: typeof raw.description === 'string' ? raw.description : null,
258
+ tags,
259
+ }
260
+ return { kind: 'activity', ...payload }
261
+ }
262
+
263
+ return null
264
+ }
265
+
266
+ function tryParseRecordCard(
267
+ info: string,
268
+ body: string,
269
+ ): RecordCardPayload | null {
270
+ if (!info.startsWith(RECORD_CARD_FENCE_INFO_PREFIX)) return null
271
+ const kind = coerceKind(info.slice(RECORD_CARD_FENCE_INFO_PREFIX.length))
272
+ if (!kind) return null
273
+ const trimmed = body.trim()
274
+ if (!trimmed) return null
275
+ let parsed: unknown
276
+ try {
277
+ parsed = JSON.parse(trimmed)
278
+ } catch {
279
+ return null
280
+ }
281
+ if (Array.isArray(parsed)) {
282
+ // Take the first entry; multiple cards can be emitted as multiple fences.
283
+ if (parsed.length === 0) return null
284
+ parsed = parsed[0]
285
+ }
286
+ if (!parsed || typeof parsed !== 'object') return null
287
+ return normalizeRecordPayload(kind, parsed as Record<string, unknown>)
288
+ }
289
+
290
+ /**
291
+ * Recognise an `open-mercato:<kind> { ... }` token the model emitted
292
+ * WITHOUT triple backticks and pull the JSON object out by counting
293
+ * matching braces. Returns the parsed payload + the slice that should be
294
+ * removed from the surrounding text.
295
+ *
296
+ * Models routinely drop the fence on this pattern (especially when the
297
+ * card is one of many in a list) and the fallback renders the line as
298
+ * plain prose, which is the user-visible bug we are guarding against.
299
+ */
300
+ function tryParseFencelessRecordCard(
301
+ text: string,
302
+ startInfoIndex: number,
303
+ ): { payload: RecordCardPayload; rawStart: number; rawEnd: number } | null {
304
+ const infoPrefix = text.slice(startInfoIndex)
305
+ if (!infoPrefix.startsWith(RECORD_CARD_FENCE_INFO_PREFIX)) return null
306
+ // Read the kind up to the first whitespace, brace, or end-of-line.
307
+ const afterPrefix = startInfoIndex + RECORD_CARD_FENCE_INFO_PREFIX.length
308
+ let kindEnd = afterPrefix
309
+ while (kindEnd < text.length) {
310
+ const ch = text[kindEnd]
311
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' || ch === '{') break
312
+ kindEnd += 1
313
+ }
314
+ const kind = coerceKind(text.slice(afterPrefix, kindEnd))
315
+ if (!kind) return null
316
+ // Skip whitespace/newlines until we find the opening brace.
317
+ let braceStart = kindEnd
318
+ while (braceStart < text.length) {
319
+ const ch = text[braceStart]
320
+ if (ch === '{') break
321
+ if (ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r') return null
322
+ braceStart += 1
323
+ }
324
+ if (braceStart >= text.length || text[braceStart] !== '{') return null
325
+ // Walk forward counting brace depth, respecting strings.
326
+ let depth = 0
327
+ let inString = false
328
+ let escaped = false
329
+ let braceEnd = -1
330
+ for (let i = braceStart; i < text.length; i += 1) {
331
+ const ch = text[i]
332
+ if (escaped) {
333
+ escaped = false
334
+ continue
335
+ }
336
+ if (inString) {
337
+ if (ch === '\\') { escaped = true; continue }
338
+ if (ch === '"') { inString = false; continue }
339
+ continue
340
+ }
341
+ if (ch === '"') { inString = true; continue }
342
+ if (ch === '{') depth += 1
343
+ else if (ch === '}') {
344
+ depth -= 1
345
+ if (depth === 0) { braceEnd = i + 1; break }
346
+ }
347
+ }
348
+ if (braceEnd < 0) return null
349
+ const jsonSlice = text.slice(braceStart, braceEnd)
350
+ let parsed: unknown
351
+ try {
352
+ parsed = JSON.parse(jsonSlice)
353
+ } catch {
354
+ return null
355
+ }
356
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
357
+ const payload = normalizeRecordPayload(kind, parsed as Record<string, unknown>)
358
+ if (!payload) return null
359
+ return { payload, rawStart: startInfoIndex, rawEnd: braceEnd }
360
+ }
361
+
362
+ /**
363
+ * Walk through a markdown segment and pull every `open-mercato:<kind>
364
+ * { ... }` token (whether or not the model wrapped it in backticks) into
365
+ * a record-card segment. Trailing/leading whitespace around the lifted
366
+ * card is normalised so the surrounding prose stays clean.
367
+ */
368
+ function liftFencelessCards(text: string): AiMessageContentSegment[] {
369
+ const out: AiMessageContentSegment[] = []
370
+ let cursor = 0
371
+ while (cursor < text.length) {
372
+ const next = text.indexOf(RECORD_CARD_FENCE_INFO_PREFIX, cursor)
373
+ if (next < 0) break
374
+ const recovered = tryParseFencelessRecordCard(text, next)
375
+ if (!recovered) {
376
+ // Skip this occurrence to avoid an infinite loop.
377
+ cursor = next + RECORD_CARD_FENCE_INFO_PREFIX.length
378
+ continue
379
+ }
380
+ if (recovered.rawStart > cursor) {
381
+ const head = text.slice(cursor, recovered.rawStart)
382
+ if (head.trim().length > 0 || head.length > 0) {
383
+ // Keep whitespace so list separators between cards survive.
384
+ out.push({ kind: 'markdown', text: head })
385
+ }
386
+ }
387
+ out.push({
388
+ kind: 'record-card',
389
+ payload: recovered.payload,
390
+ raw: text.slice(recovered.rawStart, recovered.rawEnd),
391
+ })
392
+ cursor = recovered.rawEnd
393
+ }
394
+ if (cursor < text.length) {
395
+ out.push({ kind: 'markdown', text: text.slice(cursor) })
396
+ }
397
+ return out.length > 0 ? out : [{ kind: 'markdown', text }]
398
+ }
399
+
400
+ /**
401
+ * Split assistant text into ordered segments: markdown chunks interleaved
402
+ * with parsed record-card payloads. Open / closing fences are matched
403
+ * greedily — an unterminated fence is treated as still-in-flight markdown
404
+ * (so partial streaming output never renders a half-built card). After
405
+ * the fence pass, any remaining markdown segment is scanned for
406
+ * `open-mercato:<kind> { ... }` tokens the model emitted without
407
+ * backticks (a common LLM drift) and those are lifted into card segments
408
+ * as well.
409
+ */
410
+ export function parseAiContentSegments(content: string): AiMessageContentSegment[] {
411
+ if (!content) return []
412
+ const fences = findFences(content)
413
+ const fenceSegments: AiMessageContentSegment[] = []
414
+ if (fences.length === 0) {
415
+ fenceSegments.push({ kind: 'markdown', text: content })
416
+ } else {
417
+ let cursor = 0
418
+ for (const fence of fences) {
419
+ if (!fence.info.startsWith(RECORD_CARD_FENCE_INFO_PREFIX)) {
420
+ // Plain code fence — leave it for the markdown renderer.
421
+ continue
422
+ }
423
+ if (!fence.closed) {
424
+ // Streaming a card body: keep the leading text rendered, swallow the
425
+ // half-arrived block until it closes.
426
+ if (fence.start > cursor) {
427
+ fenceSegments.push({ kind: 'markdown', text: content.slice(cursor, fence.start) })
428
+ }
429
+ cursor = content.length
430
+ break
431
+ }
432
+ if (fence.start > cursor) {
433
+ fenceSegments.push({ kind: 'markdown', text: content.slice(cursor, fence.start) })
434
+ }
435
+ const payload = tryParseRecordCard(fence.info, fence.body)
436
+ if (payload) {
437
+ fenceSegments.push({
438
+ kind: 'record-card',
439
+ payload,
440
+ raw: content.slice(fence.start, fence.end),
441
+ })
442
+ } else {
443
+ fenceSegments.push({
444
+ kind: 'invalid-card',
445
+ info: fence.info,
446
+ raw: content.slice(fence.start, fence.end),
447
+ })
448
+ }
449
+ cursor = fence.end
450
+ }
451
+ if (cursor < content.length) {
452
+ fenceSegments.push({ kind: 'markdown', text: content.slice(cursor) })
453
+ }
454
+ }
455
+ // Recovery pass: lift fenceless `open-mercato:<kind> { ... }` tokens out
456
+ // of any remaining markdown segments. The model often forgets the
457
+ // triple-backtick wrapper, especially when emitting many cards in a row.
458
+ const out: AiMessageContentSegment[] = []
459
+ for (const segment of fenceSegments) {
460
+ if (segment.kind !== 'markdown') {
461
+ out.push(segment)
462
+ continue
463
+ }
464
+ if (!segment.text.includes(RECORD_CARD_FENCE_INFO_PREFIX)) {
465
+ out.push(segment)
466
+ continue
467
+ }
468
+ out.push(...liftFencelessCards(segment.text))
469
+ }
470
+ return out
471
+ }
472
+
473
+ export interface AiMessageContentProps {
474
+ content: string
475
+ className?: string
476
+ }
477
+
478
+ export function AiMessageContent({ content, className }: AiMessageContentProps) {
479
+ const segments = React.useMemo(() => parseAiContentSegments(content), [content])
480
+ if (segments.length === 0) {
481
+ return null
482
+ }
483
+ return (
484
+ <div className={className} data-ai-message-content="">
485
+ {segments.map((segment, index) => {
486
+ if (segment.kind === 'record-card') {
487
+ return <RecordCard key={`card-${index}`} data={segment.payload} />
488
+ }
489
+ if (segment.kind === 'invalid-card') {
490
+ return (
491
+ <pre
492
+ key={`raw-${index}`}
493
+ className="my-2 max-h-60 overflow-auto rounded-md border border-dashed border-border bg-muted p-2 text-xs"
494
+ >
495
+ {segment.raw}
496
+ </pre>
497
+ )
498
+ }
499
+ if (!segment.text.trim()) {
500
+ return null
501
+ }
502
+ return (
503
+ <MarkdownContent
504
+ key={`md-${index}`}
505
+ body={segment.text}
506
+ format="markdown"
507
+ className="ai-markdown text-sm leading-relaxed [&_a]:text-primary [&_a]:underline [&_a:hover]:text-primary/80 [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:text-[12px] [&_pre]:my-2 [&_pre]:overflow-auto [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:text-xs [&_ul]:my-1 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-1 [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:my-1 [&_h1]:mt-2 [&_h1]:text-base [&_h1]:font-semibold [&_h2]:mt-2 [&_h2]:text-sm [&_h2]:font-semibold [&_h3]:mt-2 [&_h3]:text-sm [&_h3]:font-semibold"
508
+ />
509
+ )
510
+ })}
511
+ </div>
512
+ )
513
+ }
514
+
515
+ export default AiMessageContent