@arqel-dev/ai 0.8.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Arqel Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # @arqel-dev/ai
2
+
3
+ Componentes React do Arqel AI.
4
+
5
+ Exporta `<AiTextInput>`, par React do PHP `Arqel\Ai\Fields\AiTextField`.
6
+ Veja [SKILL.md](./SKILL.md) para o contrato completo de props e
7
+ exemplos.
8
+
9
+ ## Instalação
10
+
11
+ ```bash
12
+ pnpm add @arqel-dev/ai
13
+ ```
14
+
15
+ ## Uso
16
+
17
+ ```ts
18
+ import '@arqel-dev/ai/register'; // registra no FieldRegistry de @arqel-dev/ui
19
+
20
+ // ou import direto:
21
+ import { AiTextInput } from '@arqel-dev/ai';
22
+ ```
23
+
24
+ Licença: MIT.
package/SKILL.md ADDED
@@ -0,0 +1,425 @@
1
+ # SKILL.md — arqel-dev/ai (JS)
2
+
3
+ ## Purpose
4
+
5
+ Pacote React que entrega os componentes UI do Arqel AI. Por enquanto
6
+ exporta apenas `<AiTextInput>`, espelhando o PHP
7
+ `Arqel\Ai\Fields\AiTextField` (component string `AiTextInput`).
8
+
9
+ O componente é **apresentacional** mas precisa fazer **uma chamada de
10
+ rede explícita** quando o usuário clica em "Generate with AI" — o
11
+ backend (`AiGenerateController`) é quem resolve o prompt template
12
+ (que **nunca** trafega para o cliente) e devolve `{ text }`. O
13
+ contrato de rota canônico é `POST /admin/{resource}/fields/{field}/generate`.
14
+
15
+ ## Key Contracts
16
+
17
+ ```ts
18
+ interface AiTextInputProps {
19
+ name: string;
20
+ value: string;
21
+ onChange?: (value: string) => void;
22
+ props: {
23
+ provider?: string | null;
24
+ buttonLabel: string;
25
+ maxLength?: number | null;
26
+ hasContextFields: boolean;
27
+ } | undefined;
28
+ resource?: string;
29
+ field?: string;
30
+ formData?: Record<string, unknown>;
31
+ generateUrl?: string;
32
+ csrfToken?: string;
33
+ }
34
+ ```
35
+
36
+ Comportamento ao clicar no botão:
37
+
38
+ 1. Resolve URL: `generateUrl` (override) ou
39
+ `/admin/${resource}/fields/${field}/generate`.
40
+ 2. `fetch(url, { method: 'POST', credentials: 'same-origin', headers:
41
+ { Content-Type: application/json, Accept: application/json,
42
+ X-CSRF-TOKEN: csrfToken ?? '' }, body: JSON.stringify({ formData })
43
+ })`.
44
+ 3. Sucesso (`response.ok`) → lê `{ text }` do JSON, chama
45
+ `onChange(text)` (ou seta state interno se uncontrolled), troca o
46
+ label para `Regenerate`.
47
+ 4. Falha → renderiza banner com `Generation failed (HTTP 500).` e
48
+ reabilita o botão.
49
+
50
+ ## Conventions
51
+
52
+ - TypeScript strict, sem `any` (apenas asserts pontuais comentados em
53
+ `register.tsx` para compatibilidade com a assinatura genérica do
54
+ FieldRegistry).
55
+ - Component name canônico: `AiTextInput`. Não renomear — o PHP usa
56
+ essa string em `getComponent()`.
57
+ - Lint: Biome (`pnpm lint`); testes: Vitest + Testing Library; build:
58
+ tsup.
59
+ - Side-effects ficam isolados em `dist/register.js` para tree-shaking.
60
+ - SSR-safe: nada no render path toca `window`/`document`.
61
+
62
+ ## Examples
63
+
64
+ ### Uso direto
65
+
66
+ ```tsx
67
+ import { AiTextInput } from '@arqel-dev/ai';
68
+
69
+ <AiTextInput
70
+ name="description"
71
+ value={form.description}
72
+ onChange={(v) => setForm((f) => ({ ...f, description: v }))}
73
+ props={{
74
+ provider: 'claude',
75
+ buttonLabel: 'Gerar descrição',
76
+ maxLength: 1000,
77
+ hasContextFields: true,
78
+ }}
79
+ resource="products"
80
+ field="description"
81
+ formData={form}
82
+ csrfToken={csrfToken}
83
+ />;
84
+ ```
85
+
86
+ ### Via FieldRegistry do `@arqel-dev/ui`
87
+
88
+ ```ts
89
+ // boot.ts
90
+ import '@arqel-dev/ai/register';
91
+ ```
92
+
93
+ A partir daí qualquer schema com `component: 'AiTextInput'` (que é
94
+ exatamente o que o PHP `AiTextField::getComponent()` retorna) é
95
+ renderizado pelo adapter desta lib.
96
+
97
+ ## AiTextInput (AI-007 React)
98
+
99
+ O componente fecha o slice React do ticket AI-007 — o slice PHP foi
100
+ mergeado no batch #32. Renderiza:
101
+
102
+ - `<textarea>` controlado (ou uncontrolled, se `onChange` for omitido)
103
+ com `maxLength` aplicado quando configurado.
104
+ - Botão "Generate with AI" (label do PHP via `props.buttonLabel`); vira
105
+ `Regenerate` após o primeiro sucesso.
106
+ - Spinner SVG inline + `disabled` durante a chamada.
107
+ - Banner `role="alert"` quando o `fetch` falha (com status code).
108
+ - Contador `X / maxLength` quando `maxLength` está presente.
109
+
110
+ Não toque no `aria-label` do botão sem alinhar com o PHP — testes do
111
+ form usam essa string para encontrar o trigger.
112
+
113
+ ## AiTranslateInput (AI-008 React)
114
+
115
+ Componente React que fecha o slice React do ticket AI-008 — o slice
116
+ PHP foi mergeado no batch #33 (`Arqel\Ai\Fields\AiTranslateField`,
117
+ component string `AiTranslateInput`).
118
+
119
+ ```ts
120
+ interface AiTranslateInputProps {
121
+ name: string;
122
+ value: Record<string, string> | null;
123
+ onChange?: (value: Record<string, string>) => void;
124
+ props: {
125
+ languages: string[];
126
+ defaultLanguage: string;
127
+ autoTranslate: boolean;
128
+ provider?: string | null;
129
+ } | undefined;
130
+ resource?: string;
131
+ field?: string;
132
+ translateUrl?: string;
133
+ csrfToken?: string;
134
+ }
135
+ ```
136
+
137
+ Render:
138
+
139
+ - `role="tablist"` com uma `<button role="tab">` por idioma de
140
+ `props.languages`. Tabs com tradução vazia/null carregam
141
+ `data-missing="true"` + um pontinho visual (`Missing translation`).
142
+ - Painel ativo: `<textarea>` controlado editando
143
+ `translations[activeLang]`; o `onChange` recebe sempre o objeto
144
+ completo `Record<lang, string>`.
145
+ - Botão `Translate from {defaultLanguage}` em cada tab **não-default**:
146
+ `POST` com `targetLanguages: [activeLang]`.
147
+ - Botão `Translate all missing` no topo: itera as línguas com
148
+ tradução vazia e dispara um único `POST` com `targetLanguages: missing[]`.
149
+ - Loading per-language (`Set<string>`); banner `role="alert"` quando o
150
+ `fetch` falha (HTTP code visível).
151
+
152
+ Rota canônica: `POST /admin/{resource}/fields/{field}/translate`
153
+ (override via `translateUrl`). Body:
154
+ `{ sourceLanguage, targetLanguages, sourceText }`. Resposta esperada:
155
+ `{ translations: { [lang]: text } }` — mesclada no state e propagada
156
+ via `onChange`.
157
+
158
+ ### Uso direto
159
+
160
+ ```tsx
161
+ import { AiTranslateInput } from '@arqel-dev/ai';
162
+
163
+ <AiTranslateInput
164
+ name="title"
165
+ value={form.title}
166
+ onChange={(v) => setForm((f) => ({ ...f, title: v }))}
167
+ props={{
168
+ languages: ['en', 'pt', 'es'],
169
+ defaultLanguage: 'en',
170
+ autoTranslate: false,
171
+ provider: 'claude',
172
+ }}
173
+ resource="posts"
174
+ field="title"
175
+ csrfToken={csrfToken}
176
+ />;
177
+ ```
178
+
179
+ ## AiSelectInput (AI-009 React)
180
+
181
+ Componente React que fecha o slice React do ticket AI-009 — o slice
182
+ PHP foi mergeado no batch #34 (`Arqel\Ai\Fields\AiSelectField`,
183
+ component string `AiSelectInput`).
184
+
185
+ ```ts
186
+ interface AiSelectInputProps {
187
+ name: string;
188
+ value: string | null;
189
+ onChange?: (value: string | null) => void;
190
+ props: {
191
+ options: Record<string, string>;
192
+ classifyFromFields: string[];
193
+ provider?: string | null;
194
+ fallbackOption?: string | null;
195
+ hasContextFields: boolean;
196
+ } | undefined;
197
+ resource?: string;
198
+ field?: string;
199
+ formData?: Record<string, unknown>;
200
+ classifyUrl?: string;
201
+ csrfToken?: string;
202
+ }
203
+ ```
204
+
205
+ Render:
206
+
207
+ - `<select>` controlado (uncontrolled fallback se `onChange` for
208
+ omitido) com `<option value="">Select...</option>` mais uma
209
+ `<option key={k} value={k}>{label}</option>` por entry de
210
+ `props.options`.
211
+ - Botão `Classify with AI` ao lado do select. `disabled` quando
212
+ `hasContextFields` é `false` (com `title` explicando o motivo —
213
+ exibido como tooltip nativo).
214
+ - Loading state: spinner SVG inline + botão `disabled` durante o
215
+ `fetch`.
216
+ - Em sucesso (`key !== null`): `onChange(key)` é chamado e um banner
217
+ sutil `Suggested by AI` aparece com botões `Accept` e `Pick another`
218
+ (ambos apenas dismissam o banner — o valor já está aplicado no
219
+ state).
220
+ - Em sucesso com `key: null` + `fallbackOption` configurado: aplica o
221
+ fallback e mostra o banner `Used fallback`.
222
+ - Em sucesso com `key: null` sem fallback: banner `role="alert"` com
223
+ `Could not classify`.
224
+ - Em falha HTTP: banner `role="alert"` com o status code visível.
225
+
226
+ Rota canônica: `POST /admin/{resource}/fields/{field}/classify`
227
+ (override via `classifyUrl`). Body: `{ formData }`. Resposta esperada:
228
+ `{ key: string | null, label: string | null }`.
229
+
230
+ ### Uso direto
231
+
232
+ ```tsx
233
+ import { AiSelectInput } from '@arqel-dev/ai';
234
+
235
+ <AiSelectInput
236
+ name="priority"
237
+ value={form.priority}
238
+ onChange={(v) => setForm((f) => ({ ...f, priority: v }))}
239
+ props={{
240
+ options: { low: 'Low', medium: 'Medium', high: 'High' },
241
+ classifyFromFields: ['title', 'body'],
242
+ provider: 'claude',
243
+ fallbackOption: 'low',
244
+ hasContextFields: true,
245
+ }}
246
+ resource="tickets"
247
+ field="priority"
248
+ formData={form}
249
+ csrfToken={csrfToken}
250
+ />;
251
+ ```
252
+
253
+ ## AiExtractInput (AI-010 React)
254
+
255
+ Componente React que fecha o slice React do ticket AI-010 — o slice
256
+ PHP foi mergeado no batch #35 (`Arqel\Ai\Fields\AiExtractField`,
257
+ component string `AiExtractInput`).
258
+
259
+ ```ts
260
+ interface AiExtractInputProps {
261
+ name: string;
262
+ value: Record<string, unknown> | null;
263
+ onChange?: (value: Record<string, unknown>) => void;
264
+ props: {
265
+ sourceField: string;
266
+ targetFields: string[];
267
+ buttonLabel: string;
268
+ usingJsonMode: boolean;
269
+ provider?: string | null;
270
+ } | undefined;
271
+ resource?: string;
272
+ field?: string;
273
+ formData?: Record<string, unknown>;
274
+ extractUrl?: string;
275
+ csrfToken?: string;
276
+ onPopulateField?: (targetField: string, value: unknown) => void;
277
+ }
278
+ ```
279
+
280
+ Render:
281
+
282
+ - Label `Source: {sourceField}` indicando o campo do formulário usado
283
+ como texto-fonte.
284
+ - Botão `Extract with AI` (label vem de `props.buttonLabel`); spinner
285
+ inline + `disabled` durante o `fetch`.
286
+ - Empty state `No extraction yet — click button to start` antes da
287
+ primeira extração.
288
+ - Após sucesso: `<dl>` preview com cada `targetField: extractedValue`;
289
+ cada entry tem botão `Apply` individual + toolbar `Apply all`.
290
+ - `Apply` chama `onPopulateField?.(targetField, value)` quando provido,
291
+ caso contrário emite `onChange({ [target]: value })` ou
292
+ `onChange(extracted)` para `Apply all`.
293
+ - Banner `role="alert"` quando o `fetch` falha; usa `message` da
294
+ response 422 quando disponível, senão fallback genérico com HTTP
295
+ code.
296
+
297
+ Rota canônica: `POST /admin/{resource}/fields/{field}/extract`
298
+ (override via `extractUrl`). Body: `{ sourceText }` (lido de
299
+ `formData?.[sourceField]`). Resposta esperada:
300
+ `{ extracted: Record<string, unknown> }` (200) ou `{ message }` (422).
301
+
302
+ ### Uso direto
303
+
304
+ ```tsx
305
+ import { AiExtractInput } from '@arqel-dev/ai';
306
+
307
+ <AiExtractInput
308
+ name="contact"
309
+ value={form.contact}
310
+ onChange={(v) => setForm((f) => ({ ...f, contact: v }))}
311
+ props={{
312
+ sourceField: 'rawText',
313
+ targetFields: ['name', 'email', 'phone'],
314
+ buttonLabel: 'Extract contact info',
315
+ usingJsonMode: true,
316
+ provider: 'claude',
317
+ }}
318
+ resource="contacts"
319
+ field="contact"
320
+ formData={form}
321
+ csrfToken={csrfToken}
322
+ onPopulateField={(target, value) =>
323
+ setForm((f) => ({ ...f, [target]: value }))
324
+ }
325
+ />;
326
+ ```
327
+
328
+ ## AiImageInput (AI-011 React)
329
+
330
+ Componente React que fecha o slice React do ticket AI-011 — o slice
331
+ PHP foi mergeado no batch #36 (`Arqel\Ai\Fields\AiImageField`,
332
+ component string `AiImageInput`).
333
+
334
+ ```ts
335
+ interface AiImageInputProps {
336
+ name: string;
337
+ value: string | null;
338
+ onChange?: (value: string) => void;
339
+ props: {
340
+ analyses: string[];
341
+ populateFields: Record<string, string>;
342
+ provider?: string | null;
343
+ acceptedMimes: string[];
344
+ maxFileSize: number;
345
+ buttonLabel: string;
346
+ } | undefined;
347
+ resource?: string;
348
+ field?: string;
349
+ analyzeUrl?: string;
350
+ csrfToken?: string;
351
+ onPopulateField?: (targetField: string, value: string) => void;
352
+ }
353
+ ```
354
+
355
+ Render:
356
+
357
+ - `<input type="file">` escondido + `<label>` clicável formando o
358
+ drop-zone visual; `accept` é o `props.acceptedMimes.join(',')`.
359
+ - Preview da imagem após selecionar (via `URL.createObjectURL`, só
360
+ chamado dentro do change handler — render path SSR-safe).
361
+ - Validação client-side: arquivos maiores que `props.maxFileSize`
362
+ (em bytes) viram banner `role="alert"` + botão fica `disabled`.
363
+ - Botão `Analyze with AI` (label vem de `props.buttonLabel`); spinner
364
+ inline + `disabled` durante o `fetch`. Sem arquivo selecionado o
365
+ botão também fica `disabled`.
366
+ - Após sucesso: `<dl>` preview com cada `analysis_key: value`; cada
367
+ entry com mapping em `populateFields` mostra botão `Apply`
368
+ individual; toolbar mostra `Apply all` que itera todas as analyses
369
+ com mapping.
370
+ - `Apply` chama `onPopulateField?.(target, value)` usando o
371
+ mapeamento `populateFields[analysisKey]` (ou `populateMapping`
372
+ vindo da response, que tem precedência se presente).
373
+ - Banner `role="alert"` quando o `fetch` falha; usa `message` da
374
+ response 422 quando disponível, senão fallback genérico com HTTP
375
+ code.
376
+
377
+ Rota canônica: `POST /admin/{resource}/fields/{field}/analyze-image`
378
+ (override via `analyzeUrl`). Body: `{ imageBase64 }` (data URI
379
+ completo, produzido por `FileReader.readAsDataURL`). Resposta
380
+ esperada: `{ analyses: Record<string,string>, populateMapping:
381
+ Record<string,string> }` (200) ou `{ message }` (422).
382
+
383
+ ### Uso direto
384
+
385
+ ```tsx
386
+ import { AiImageInput } from '@arqel-dev/ai';
387
+
388
+ <AiImageInput
389
+ name="cover"
390
+ value={form.cover}
391
+ onChange={(v) => setForm((f) => ({ ...f, cover: v }))}
392
+ props={{
393
+ analyses: ['alt_text', 'tags'],
394
+ populateFields: { alt_text: 'cover_alt', tags: 'cover_tags' },
395
+ provider: 'claude',
396
+ acceptedMimes: ['image/jpeg', 'image/png', 'image/webp'],
397
+ maxFileSize: 10_485_760,
398
+ buttonLabel: 'Analyze with AI',
399
+ }}
400
+ resource="posts"
401
+ field="cover"
402
+ csrfToken={csrfToken}
403
+ onPopulateField={(target, value) =>
404
+ setForm((f) => ({ ...f, [target]: value }))
405
+ }
406
+ />;
407
+ ```
408
+
409
+ ## Anti-patterns
410
+
411
+ - Trazer o prompt template para o cliente — segurança/IP. O backend
412
+ resolve `{fieldName}` placeholders.
413
+ - Adicionar TanStack Query / SWR / Inertia router só para esse fetch:
414
+ uma chamada `fetch` simples basta (ADR-016).
415
+ - Renomear o component name para algo sem casamento com o PHP — o
416
+ field aponta exatamente para `AiTextInput`.
417
+ - Tocar `window`/`document` durante render — quebra SSR.
418
+
419
+ ## Related
420
+
421
+ - PHP field: `packages/ai/src/Fields/AiTextField.php`
422
+ - PHP controller: `packages/ai/src/Http/Controllers/AiGenerateController.php`
423
+ - Rota: `packages/ai/routes/web.php` (`arqel.ai.generate`)
424
+ - Ticket: `PLANNING/10-fase-3-avancadas.md` → AI-007
425
+ - FieldRegistry: `packages-js/ui/src/form/FieldRegistry.tsx`