arkaos 2.78.0 → 2.80.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/VERSION CHANGED
@@ -1 +1 @@
1
- 2.78.0
1
+ 2.80.0
@@ -0,0 +1,112 @@
1
+ <script setup lang="ts">
2
+ // PR64 v2.80.0 — shared loading / error / empty wrapper.
3
+ //
4
+ // Every page in dashboard/app/pages/ used to duplicate the same triple:
5
+ //
6
+ // <div v-if="status === 'pending'"><spinner/></div>
7
+ // <div v-else-if="error"><alert + Retry/></div>
8
+ // <template v-else>... content ...</template>
9
+ //
10
+ // Five copies of that pattern with subtle drift (different icon sizes,
11
+ // different empty-state shapes, inconsistent ARIA roles, some with
12
+ // retry buttons missing). PR64 extracts it into one component so the
13
+ // rest of the dashboard work (PR63 Settings, PR65 Budget, PR66 Index
14
+ // rebuild, etc.) inherits a consistent shell.
15
+ //
16
+ // Slots:
17
+ // default — the success/content block (only rendered on 'success')
18
+ // empty — optional override for the empty state (defaults to
19
+ // generic "no data" with the empty-* props below)
20
+ // loading — optional override for the spinner (rarely needed)
21
+ // error — optional override for the error state (rarely needed)
22
+ //
23
+ // The component never owns the data — pages still call useFetch /
24
+ // fetchApi and pass `status` + `error` + an `empty` boolean in.
25
+
26
+ import type { AsyncDataRequestStatus } from 'nuxt/app'
27
+
28
+ interface Props {
29
+ /** useFetch/useAsyncData status. 'pending' shows spinner. */
30
+ status: AsyncDataRequestStatus
31
+ /** Error from useFetch — present means render the error block. */
32
+ error?: Error | null
33
+ /** True when the request succeeded but returned no rows.
34
+ * Pages compute this from their data shape (e.g. `!list.length`). */
35
+ empty?: boolean
36
+ /** Heading for the default empty state. */
37
+ emptyTitle?: string
38
+ /** Helper text for the default empty state. */
39
+ emptyDescription?: string
40
+ /** Icon for the default empty state. Defaults to inbox. */
41
+ emptyIcon?: string
42
+ /** Optional retry handler — when provided, the error block shows a button. */
43
+ onRetry?: () => void | Promise<void>
44
+ /** Optional ARIA label for the loading region. */
45
+ loadingLabel?: string
46
+ }
47
+
48
+ const props = withDefaults(defineProps<Props>(), {
49
+ error: null,
50
+ empty: false,
51
+ emptyTitle: 'No data',
52
+ emptyDescription: '',
53
+ emptyIcon: 'i-lucide-inbox',
54
+ loadingLabel: 'Loading',
55
+ })
56
+ </script>
57
+
58
+ <template>
59
+ <!-- Loading -->
60
+ <div
61
+ v-if="status === 'pending'"
62
+ class="flex items-center justify-center py-12"
63
+ :aria-label="loadingLabel"
64
+ role="status"
65
+ >
66
+ <slot name="loading">
67
+ <UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-muted" />
68
+ </slot>
69
+ </div>
70
+
71
+ <!-- Error -->
72
+ <div
73
+ v-else-if="error"
74
+ class="flex flex-col items-center justify-center gap-4 py-12"
75
+ role="alert"
76
+ >
77
+ <slot name="error" :error="error">
78
+ <UIcon name="i-lucide-alert-triangle" class="size-12 text-red-500" />
79
+ <p class="text-sm text-muted">
80
+ {{ error.message || 'Failed to load data.' }}
81
+ </p>
82
+ <UButton
83
+ v-if="onRetry"
84
+ label="Retry"
85
+ variant="outline"
86
+ color="primary"
87
+ icon="i-lucide-refresh-cw"
88
+ @click="onRetry"
89
+ />
90
+ </slot>
91
+ </div>
92
+
93
+ <!-- Empty -->
94
+ <div
95
+ v-else-if="empty"
96
+ class="flex flex-col items-center justify-center gap-4 py-16"
97
+ >
98
+ <slot name="empty">
99
+ <UIcon :name="emptyIcon" class="size-16 text-muted" />
100
+ <h3 class="text-lg font-semibold text-highlighted">{{ emptyTitle }}</h3>
101
+ <p
102
+ v-if="emptyDescription"
103
+ class="text-sm text-muted text-center max-w-md"
104
+ >
105
+ {{ emptyDescription }}
106
+ </p>
107
+ </slot>
108
+ </div>
109
+
110
+ <!-- Content -->
111
+ <slot v-else />
112
+ </template>
@@ -0,0 +1,504 @@
1
+ <script setup lang="ts">
2
+ import type { Persona } from '~/types'
3
+
4
+ // PR62 v2.79.0 — 4-step AI-powered persona wizard.
5
+ // Replaces the original "fill a 30-field form" UX with:
6
+ // 1. Sources → operator pastes URLs / picks files, optionally types a name
7
+ // 2. Ingest → existing /api/knowledge/ingest-bulk fans the URLs out into
8
+ // background jobs and shows progress via WebSocket
9
+ // 3. Build → /api/personas/build (PR57) reads the indexed chunks and
10
+ // returns a draft Persona; operator reviews + edits
11
+ // 4. Save → POST /api/personas (manual create path), optional
12
+ // clone-to-agent at the end
13
+ //
14
+ // The wizard NEVER auto-saves — every transition is operator-confirmed.
15
+
16
+ const { apiBase } = useApi()
17
+ const toast = useToast()
18
+
19
+ const emit = defineEmits<{
20
+ (e: 'completed', persona: Persona): void
21
+ (e: 'cancelled'): void
22
+ }>()
23
+
24
+ type Step = 1 | 2 | 3 | 4
25
+ const step = ref<Step>(1)
26
+
27
+ // ─── Step 1 state ────────────────────────────────────────────────────────
28
+ const name = ref('')
29
+ const sourceLabel = ref('')
30
+ const sources = ref('')
31
+ const skipIngest = ref(false)
32
+ const sourceLineCount = computed(() =>
33
+ sources.value
34
+ .split('\n')
35
+ .map((s) => s.trim())
36
+ .filter((s) => s.length > 0)
37
+ .length,
38
+ )
39
+
40
+ // ─── Step 2 state ────────────────────────────────────────────────────────
41
+ const ingestJobs = ref<Array<{
42
+ source: string
43
+ job_id?: string
44
+ status: 'queued' | 'processing' | 'completed' | 'failed'
45
+ progress: number
46
+ error?: string
47
+ }>>([])
48
+ let ws: WebSocket | null = null
49
+ const allIngestComplete = computed(() =>
50
+ ingestJobs.value.length > 0
51
+ && ingestJobs.value.every((j) => j.status === 'completed' || j.status === 'failed'),
52
+ )
53
+ const ingestCompletedCount = computed(() =>
54
+ ingestJobs.value.filter((j) => j.status === 'completed').length,
55
+ )
56
+
57
+ // ─── Step 3 state ────────────────────────────────────────────────────────
58
+ const draft = ref<Persona | null>(null)
59
+ const building = ref(false)
60
+ const buildError = ref<string | null>(null)
61
+ const chunksUsed = ref<number | null>(null)
62
+
63
+ // ─── Step 4 state ────────────────────────────────────────────────────────
64
+ const saving = ref(false)
65
+ const saveAndClone = ref(false)
66
+ const cloneDept = ref('strategy')
67
+ const cloneTier = ref<'1' | '2' | '3'>('2')
68
+
69
+ const departmentOptions = [
70
+ 'dev', 'marketing', 'brand', 'finance', 'strategy', 'ecom', 'kb', 'ops',
71
+ 'pm', 'saas', 'landing', 'content', 'community', 'sales', 'leadership', 'org',
72
+ ].map((d) => ({ label: d, value: d }))
73
+
74
+ const tierOptions = [
75
+ { label: 'Tier 1 — Squad Lead', value: '1' },
76
+ { label: 'Tier 2 — Specialist', value: '2' },
77
+ { label: 'Tier 3 — Support', value: '3' },
78
+ ]
79
+
80
+ // ─── Step 1 → 2 transition ───────────────────────────────────────────────
81
+
82
+
83
+ async function startIngest() {
84
+ if (skipIngest.value) {
85
+ // Jump straight to step 3 — operator says content is already indexed.
86
+ step.value = 3
87
+ await runBuild()
88
+ return
89
+ }
90
+ const cleaned = sources.value
91
+ .split('\n')
92
+ .map((s) => s.trim())
93
+ .filter((s) => s.length > 0)
94
+ if (cleaned.length === 0 || !name.value.trim()) return
95
+ step.value = 2
96
+ ingestJobs.value = cleaned.map((source) => ({
97
+ source,
98
+ status: 'queued',
99
+ progress: 0,
100
+ }))
101
+ try {
102
+ const res = await $fetch<{ jobs: Array<{ source: string, job_id?: string, error?: string }>, count: number }>(
103
+ `${apiBase}/api/knowledge/ingest-bulk`,
104
+ { method: 'POST', body: { sources: cleaned } },
105
+ )
106
+ res.jobs.forEach((j) => {
107
+ const row = ingestJobs.value.find((r) => r.source === j.source)
108
+ if (!row) return
109
+ if (j.error) {
110
+ row.status = 'failed'
111
+ row.error = j.error
112
+ } else if (j.job_id) {
113
+ row.job_id = j.job_id
114
+ row.status = 'processing'
115
+ }
116
+ })
117
+ connectWebSocket()
118
+ } catch (err) {
119
+ toast.add({
120
+ title: 'Ingest failed',
121
+ description: err instanceof Error ? err.message : 'unknown error',
122
+ color: 'error',
123
+ })
124
+ step.value = 1
125
+ }
126
+ }
127
+
128
+
129
+ function connectWebSocket() {
130
+ if (ws && ws.readyState === WebSocket.OPEN) return
131
+ const wsUrl = apiBase.replace('http://', 'ws://').replace('https://', 'wss://') + '/ws/tasks'
132
+ ws = new WebSocket(wsUrl)
133
+ ws.onmessage = (event) => {
134
+ try {
135
+ const data = JSON.parse(event.data)
136
+ const row = ingestJobs.value.find((j) => j.job_id === data.job_id)
137
+ if (!row) return
138
+ if (data.type === 'job_progress') {
139
+ row.progress = data.progress
140
+ row.status = 'processing'
141
+ } else if (data.type === 'job_complete') {
142
+ row.status = 'completed'
143
+ row.progress = 100
144
+ } else if (data.type === 'job_failed') {
145
+ row.status = 'failed'
146
+ row.error = data.error
147
+ }
148
+ } catch { /* ignore malformed messages */ }
149
+ }
150
+ }
151
+
152
+
153
+ function disconnectWebSocket() {
154
+ if (ws) {
155
+ try { ws.close() } catch { /* already closed */ }
156
+ ws = null
157
+ }
158
+ }
159
+
160
+
161
+ onBeforeUnmount(() => {
162
+ disconnectWebSocket()
163
+ })
164
+
165
+
166
+ // ─── Step 3: build the persona draft ────────────────────────────────────
167
+
168
+
169
+ async function runBuild() {
170
+ building.value = true
171
+ buildError.value = null
172
+ draft.value = null
173
+ try {
174
+ const res = await $fetch<{ persona: Persona, chunks_used: number, provider_name: string }>(
175
+ `${apiBase}/api/personas/build`,
176
+ {
177
+ method: 'POST',
178
+ body: {
179
+ name: name.value.trim(),
180
+ source_label: sourceLabel.value.trim() || name.value.trim(),
181
+ },
182
+ },
183
+ )
184
+ if ('error' in res && typeof (res as any).error === 'string') {
185
+ throw new Error((res as any).error)
186
+ }
187
+ draft.value = res.persona
188
+ chunksUsed.value = res.chunks_used
189
+ step.value = 4
190
+ } catch (err) {
191
+ buildError.value = err instanceof Error ? err.message : 'unknown error'
192
+ } finally {
193
+ building.value = false
194
+ }
195
+ }
196
+
197
+
198
+ // ─── Step 4: save + optional clone ──────────────────────────────────────
199
+
200
+
201
+ async function savePersona() {
202
+ if (!draft.value) return
203
+ saving.value = true
204
+ try {
205
+ const created = await $fetch<Persona>(`${apiBase}/api/personas`, {
206
+ method: 'POST',
207
+ body: draft.value,
208
+ })
209
+ if (saveAndClone.value && cloneDept.value && cloneTier.value) {
210
+ await $fetch(`${apiBase}/api/personas/${created.id}/clone`, {
211
+ method: 'POST',
212
+ body: { department: cloneDept.value, tier: Number(cloneTier.value) },
213
+ })
214
+ }
215
+ toast.add({
216
+ title: saveAndClone.value ? 'Persona saved + agent cloned' : 'Persona saved',
217
+ description: `${created.name} is now in your board.`,
218
+ color: 'success',
219
+ })
220
+ emit('completed', created)
221
+ } catch (err) {
222
+ toast.add({
223
+ title: 'Save failed',
224
+ description: err instanceof Error ? err.message : 'unknown error',
225
+ color: 'error',
226
+ })
227
+ } finally {
228
+ saving.value = false
229
+ }
230
+ }
231
+
232
+
233
+ // ─── Auto-advance when ingest completes ─────────────────────────────────
234
+
235
+
236
+ watch(allIngestComplete, async (done) => {
237
+ if (done && step.value === 2 && ingestCompletedCount.value > 0) {
238
+ step.value = 3
239
+ await runBuild()
240
+ }
241
+ })
242
+
243
+
244
+ function cancel() {
245
+ disconnectWebSocket()
246
+ emit('cancelled')
247
+ }
248
+
249
+
250
+ function backToStep1() {
251
+ disconnectWebSocket()
252
+ step.value = 1
253
+ ingestJobs.value = []
254
+ }
255
+ </script>
256
+
257
+ <template>
258
+ <UCard>
259
+ <template #header>
260
+ <div class="flex items-center justify-between">
261
+ <div>
262
+ <h3 class="text-lg font-semibold">AI Persona Builder</h3>
263
+ <p class="text-sm text-muted mt-1">
264
+ Step {{ step }} of 4 — {{ {
265
+ 1: 'Sources',
266
+ 2: 'Indexing',
267
+ 3: 'Generating DNA',
268
+ 4: 'Review & save',
269
+ }[step] }}
270
+ </p>
271
+ </div>
272
+ <UButton
273
+ label="Cancel"
274
+ variant="ghost"
275
+ color="neutral"
276
+ size="sm"
277
+ @click="cancel"
278
+ />
279
+ </div>
280
+ </template>
281
+
282
+ <!-- Progress indicator -->
283
+ <div class="flex items-center gap-2 mb-6">
284
+ <div
285
+ v-for="s in [1, 2, 3, 4] as Step[]"
286
+ :key="s"
287
+ class="flex-1 h-1 rounded-full"
288
+ :class="s <= step ? 'bg-primary' : 'bg-muted/30'"
289
+ />
290
+ </div>
291
+
292
+ <!-- Step 1: Sources -->
293
+ <div v-if="step === 1" class="space-y-4">
294
+ <UFormField label="Person name" required>
295
+ <UInput
296
+ v-model="name"
297
+ placeholder="e.g. Alex Hormozi"
298
+ size="lg"
299
+ class="w-full"
300
+ />
301
+ </UFormField>
302
+
303
+ <UFormField label="Source label (optional)" help="How this person should appear in the persona's source field. Defaults to the name above.">
304
+ <UInput
305
+ v-model="sourceLabel"
306
+ placeholder="e.g. Alex Hormozi — $100M Offers / $100M Leads"
307
+ class="w-full"
308
+ />
309
+ </UFormField>
310
+
311
+ <UFormField
312
+ label="Sources (one URL per line)"
313
+ help="YouTube videos, articles, PDFs, blog posts about this person. The builder will search the indexed chunks and synthesise their behavioural DNA. Up to 50 sources per batch."
314
+ >
315
+ <UTextarea
316
+ v-model="sources"
317
+ :rows="6"
318
+ placeholder="https://www.youtube.com/watch?v=...&#10;https://example.com/article&#10;https://example.com/paper.pdf"
319
+ class="w-full font-mono text-sm"
320
+ :disabled="skipIngest"
321
+ />
322
+ </UFormField>
323
+
324
+ <div class="flex items-center justify-between text-xs text-muted">
325
+ <span>{{ sourceLineCount }} source{{ sourceLineCount === 1 ? '' : 's' }} detected</span>
326
+ <UCheckbox
327
+ v-model="skipIngest"
328
+ label="Skip ingest — content for this person is already in the knowledge base"
329
+ />
330
+ </div>
331
+
332
+ <div class="flex justify-end gap-2 pt-4">
333
+ <UButton
334
+ :label="skipIngest ? 'Generate from existing knowledge' : `Index ${sourceLineCount} source${sourceLineCount === 1 ? '' : 's'} & build`"
335
+ icon="i-lucide-arrow-right"
336
+ :disabled="!name.trim() || (!skipIngest && sourceLineCount === 0) || sourceLineCount > 50"
337
+ size="md"
338
+ @click="startIngest"
339
+ />
340
+ </div>
341
+ <p v-if="sourceLineCount > 50" class="text-xs text-red-400">
342
+ Over the 50-source cap. Trim the list before continuing.
343
+ </p>
344
+ </div>
345
+
346
+ <!-- Step 2: Ingest progress -->
347
+ <div v-else-if="step === 2" class="space-y-4">
348
+ <p class="text-sm text-muted">
349
+ Indexing {{ ingestJobs.length }} source{{ ingestJobs.length === 1 ? '' : 's' }} into the knowledge base.
350
+ This auto-advances when complete.
351
+ </p>
352
+ <div class="space-y-2">
353
+ <div
354
+ v-for="(job, idx) in ingestJobs"
355
+ :key="idx"
356
+ class="rounded-lg border border-default p-3"
357
+ >
358
+ <div class="flex items-center gap-3">
359
+ <UIcon
360
+ :name="{
361
+ queued: 'i-lucide-clock',
362
+ processing: 'i-lucide-loader-2 animate-spin',
363
+ completed: 'i-lucide-check-circle',
364
+ failed: 'i-lucide-x-circle',
365
+ }[job.status]"
366
+ :class="{
367
+ queued: 'text-muted',
368
+ processing: 'text-primary',
369
+ completed: 'text-green-500',
370
+ failed: 'text-red-500',
371
+ }[job.status]"
372
+ class="size-4 shrink-0"
373
+ />
374
+ <div class="flex-1 min-w-0">
375
+ <p class="text-sm font-mono truncate">{{ job.source }}</p>
376
+ <UProgress
377
+ v-if="job.status === 'processing' || job.status === 'queued'"
378
+ :value="job.progress"
379
+ :max="100"
380
+ size="xs"
381
+ class="mt-1"
382
+ />
383
+ <p v-if="job.error" class="text-xs text-red-400 mt-1">{{ job.error }}</p>
384
+ </div>
385
+ <span class="text-xs text-muted">{{ job.progress }}%</span>
386
+ </div>
387
+ </div>
388
+ </div>
389
+ </div>
390
+
391
+ <!-- Step 3: Building -->
392
+ <div v-else-if="step === 3" class="space-y-4">
393
+ <div v-if="building" class="flex flex-col items-center gap-4 py-12">
394
+ <UIcon name="i-lucide-loader-2" class="size-12 animate-spin text-primary" />
395
+ <p class="text-sm text-muted">
396
+ Reading indexed chunks about <strong>{{ name }}</strong>…
397
+ </p>
398
+ <p class="text-xs text-muted">
399
+ The builder searches the vector store, joins the top chunks, and asks the configured LLM to extract a behavioural-DNA persona.
400
+ </p>
401
+ </div>
402
+ <div v-else-if="buildError" class="rounded-lg border border-red-500/20 bg-red-500/5 p-4">
403
+ <div class="flex items-start gap-3">
404
+ <UIcon name="i-lucide-alert-circle" class="size-5 text-red-500 mt-0.5 shrink-0" />
405
+ <div class="flex-1">
406
+ <p class="text-sm font-medium text-red-400">Build failed</p>
407
+ <p class="text-xs text-muted mt-1">{{ buildError }}</p>
408
+ </div>
409
+ </div>
410
+ <div class="flex gap-2 mt-3">
411
+ <UButton label="Retry" variant="outline" size="sm" @click="runBuild" />
412
+ <UButton label="Back to sources" variant="ghost" size="sm" @click="backToStep1" />
413
+ </div>
414
+ </div>
415
+ </div>
416
+
417
+ <!-- Step 4: Review & save -->
418
+ <div v-else-if="step === 4 && draft" class="space-y-4">
419
+ <div class="rounded-lg border border-green-500/20 bg-green-500/5 p-3">
420
+ <p class="text-sm text-green-400">
421
+ <UIcon name="i-lucide-sparkles" class="size-4 inline" />
422
+ Built from <strong>{{ chunksUsed }}</strong> knowledge chunk{{ chunksUsed === 1 ? '' : 's' }}. Edit any field below before saving.
423
+ </p>
424
+ </div>
425
+
426
+ <fieldset class="space-y-3">
427
+ <legend class="text-xs font-bold uppercase tracking-widest text-muted mb-2">Identity</legend>
428
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
429
+ <UFormField label="Name">
430
+ <UInput v-model="draft.name" class="w-full" />
431
+ </UFormField>
432
+ <UFormField label="Title">
433
+ <UInput v-model="draft.title" class="w-full" />
434
+ </UFormField>
435
+ </div>
436
+ <UFormField label="Tagline">
437
+ <UInput v-model="draft.tagline" class="w-full" />
438
+ </UFormField>
439
+ </fieldset>
440
+
441
+ <fieldset class="space-y-3">
442
+ <legend class="text-xs font-bold uppercase tracking-widest text-muted mb-2">Behavioural DNA</legend>
443
+ <div class="grid grid-cols-2 md:grid-cols-4 gap-3">
444
+ <UFormField label="MBTI">
445
+ <UInput v-model="draft.mbti" class="w-full" />
446
+ </UFormField>
447
+ <UFormField label="DISC primary">
448
+ <UInput v-model="draft.disc.primary" class="w-full" />
449
+ </UFormField>
450
+ <UFormField label="Enneagram type">
451
+ <UInput v-model.number="draft.enneagram.type" type="number" :min="1" :max="9" class="w-full" />
452
+ </UFormField>
453
+ <UFormField label="Enneagram wing">
454
+ <UInput v-model.number="draft.enneagram.wing" type="number" :min="1" :max="9" class="w-full" />
455
+ </UFormField>
456
+ </div>
457
+ </fieldset>
458
+
459
+ <fieldset class="space-y-3">
460
+ <legend class="text-xs font-bold uppercase tracking-widest text-muted mb-2">Knowledge</legend>
461
+ <UFormField label="Mental models" help="comma-separated">
462
+ <UInput
463
+ :model-value="draft.mental_models.join(', ')"
464
+ @update:model-value="(v: string) => draft && (draft.mental_models = v.split(',').map(s => s.trim()).filter(Boolean))"
465
+ class="w-full"
466
+ />
467
+ </UFormField>
468
+ <UFormField label="Expertise domains" help="comma-separated">
469
+ <UInput
470
+ :model-value="draft.expertise_domains.join(', ')"
471
+ @update:model-value="(v: string) => draft && (draft.expertise_domains = v.split(',').map(s => s.trim()).filter(Boolean))"
472
+ class="w-full"
473
+ />
474
+ </UFormField>
475
+ </fieldset>
476
+
477
+ <fieldset class="space-y-3">
478
+ <legend class="text-xs font-bold uppercase tracking-widest text-muted mb-2">Save options</legend>
479
+ <UCheckbox
480
+ v-model="saveAndClone"
481
+ label="Also clone to an agent immediately"
482
+ />
483
+ <div v-if="saveAndClone" class="grid grid-cols-2 gap-3 pl-6">
484
+ <UFormField label="Department">
485
+ <USelect v-model="cloneDept" :items="departmentOptions" class="w-full" />
486
+ </UFormField>
487
+ <UFormField label="Tier">
488
+ <USelect v-model="cloneTier" :items="tierOptions" class="w-full" />
489
+ </UFormField>
490
+ </div>
491
+ </fieldset>
492
+
493
+ <div class="flex justify-end gap-2 pt-4">
494
+ <UButton label="Back" variant="ghost" @click="backToStep1" />
495
+ <UButton
496
+ :label="saveAndClone ? 'Save & clone' : 'Save persona'"
497
+ icon="i-lucide-check"
498
+ :loading="saving"
499
+ @click="savePersona"
500
+ />
501
+ </div>
502
+ </div>
503
+ </UCard>
504
+ </template>
@@ -127,26 +127,15 @@ function goToAgent(id: string) {
127
127
  </template>
128
128
 
129
129
  <template #body>
130
- <!-- Loading -->
131
- <div v-if="status === 'pending'" class="flex items-center justify-center py-12">
132
- <UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-muted" />
133
- </div>
134
-
135
- <!-- Error -->
136
- <div v-else-if="error" class="flex flex-col items-center justify-center gap-4 py-12" role="alert">
137
- <UIcon name="i-lucide-alert-triangle" class="size-12 text-red-500" />
138
- <p class="text-sm text-muted">Failed to load agents.</p>
139
- <UButton label="Retry" variant="outline" color="primary" icon="i-lucide-refresh-cw" @click="refresh()" />
140
- </div>
141
-
142
- <!-- Empty -->
143
- <div v-else-if="!agents.length" class="flex flex-col items-center justify-center gap-4 py-12">
144
- <UIcon name="i-lucide-users" class="size-12 text-muted" />
145
- <p class="text-sm text-muted">No agents found.</p>
146
- </div>
147
-
148
- <!-- Content -->
149
- <template v-else>
130
+ <DashboardState
131
+ :status="status"
132
+ :error="error"
133
+ :empty="!agents.length"
134
+ empty-title="No agents found"
135
+ empty-icon="i-lucide-users"
136
+ loading-label="Loading agents"
137
+ :on-retry="() => refresh()"
138
+ >
150
139
  <div class="flex flex-wrap items-center gap-3 mb-4">
151
140
  <UInput
152
141
  v-model="search"
@@ -219,7 +208,7 @@ function goToAgent(id: string) {
219
208
  @update:page="(val) => page = val"
220
209
  />
221
210
  </div>
222
- </template>
211
+ </DashboardState>
223
212
  </template>
224
213
  </UDashboardPanel>
225
214
  </template>
@@ -37,17 +37,13 @@ const tierLabels: Record<number, string> = {
37
37
  </template>
38
38
 
39
39
  <template #body>
40
- <div v-if="status === 'pending'" class="flex items-center justify-center py-12">
41
- <UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-muted" />
42
- </div>
43
-
44
- <div v-else-if="error" class="flex flex-col items-center justify-center gap-4 py-12" role="alert">
45
- <UIcon name="i-lucide-alert-triangle" class="size-12 text-red-500" />
46
- <p class="text-sm text-muted">Failed to load budget data.</p>
47
- <UButton label="Retry" variant="outline" icon="i-lucide-refresh-cw" @click="refresh()" />
48
- </div>
49
-
50
- <div v-else class="space-y-6">
40
+ <DashboardState
41
+ :status="status"
42
+ :error="error"
43
+ loading-label="Loading budget"
44
+ :on-retry="() => refresh()"
45
+ >
46
+ <div class="space-y-6">
51
47
  <!-- Monthly Summary -->
52
48
  <UCard>
53
49
  <div class="space-y-3">
@@ -127,6 +123,7 @@ const tierLabels: Record<number, string> = {
127
123
  </div>
128
124
  </div>
129
125
  </div>
126
+ </DashboardState>
130
127
  </template>
131
128
  </UDashboardPanel>
132
129
  </template>
@@ -30,26 +30,15 @@ const allPassed = computed(() => passed.value === total.value && total.value > 0
30
30
  </template>
31
31
 
32
32
  <template #body>
33
- <!-- Loading -->
34
- <div v-if="status === 'pending'" class="flex items-center justify-center py-12">
35
- <UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-muted" />
36
- </div>
37
-
38
- <!-- Error -->
39
- <div v-else-if="error" class="flex flex-col items-center justify-center gap-4 py-12" role="alert">
40
- <UIcon name="i-lucide-alert-triangle" class="size-12 text-red-500" />
41
- <p class="text-sm text-muted">Failed to load health checks.</p>
42
- <UButton label="Retry" variant="outline" color="primary" icon="i-lucide-refresh-cw" @click="refresh()" />
43
- </div>
44
-
45
- <!-- Empty -->
46
- <div v-else-if="!checks.length" class="flex flex-col items-center justify-center gap-4 py-12">
47
- <UIcon name="i-lucide-heart-pulse" class="size-12 text-muted" />
48
- <p class="text-sm text-muted">No health checks available.</p>
49
- </div>
50
-
51
- <!-- Content -->
52
- <template v-else>
33
+ <DashboardState
34
+ :status="status"
35
+ :error="error"
36
+ :empty="!checks.length"
37
+ empty-title="No health checks available"
38
+ empty-icon="i-lucide-heart-pulse"
39
+ loading-label="Loading health checks"
40
+ :on-retry="() => refresh()"
41
+ >
53
42
  <!-- Overall Status -->
54
43
  <div
55
44
  class="mb-6 rounded-lg border p-6 text-center"
@@ -92,7 +81,7 @@ const allPassed = computed(() => passed.value === total.value && total.value > 0
92
81
  />
93
82
  </div>
94
83
  </div>
95
- </template>
84
+ </DashboardState>
96
85
  </template>
97
86
  </UDashboardPanel>
98
87
  </template>
@@ -39,20 +39,12 @@ function formatCurrency(value: number): string {
39
39
  </template>
40
40
 
41
41
  <template #body>
42
- <!-- Loading State -->
43
- <div v-if="status === 'pending'" class="flex items-center justify-center py-12">
44
- <UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-muted" />
45
- </div>
46
-
47
- <!-- Error State -->
48
- <div v-else-if="error" class="flex flex-col items-center justify-center gap-4 py-12" role="alert">
49
- <UIcon name="i-lucide-alert-triangle" class="size-12 text-red-500" />
50
- <p class="text-sm text-muted">Failed to load overview data.</p>
51
- <UButton label="Retry" variant="outline" color="primary" icon="i-lucide-refresh-cw" @click="refresh()" />
52
- </div>
53
-
54
- <!-- Content -->
55
- <template v-else>
42
+ <DashboardState
43
+ :status="status"
44
+ :error="error"
45
+ loading-label="Loading overview"
46
+ :on-retry="() => refresh()"
47
+ >
56
48
  <!-- Stats Grid -->
57
49
  <div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
58
50
  <div
@@ -120,7 +112,7 @@ function formatCurrency(value: number): string {
120
112
  </div>
121
113
  </div>
122
114
  </div>
123
- </template>
115
+ </DashboardState>
124
116
  </template>
125
117
  </UDashboardPanel>
126
118
  </template>
@@ -9,8 +9,30 @@ const { data, status, error, refresh } = fetchApi<{ personas: Persona[]; total:
9
9
 
10
10
  const personas = computed(() => data.value?.personas ?? [])
11
11
 
12
- // --- Form visibility ---
13
- const showForm = ref(false)
12
+ // --- Creation mode ---
13
+ // PR62 v2.79.0 — three modes: list (default), wizard (AI builder), manual.
14
+ // The wizard is the new primary path; manual stays as fallback for
15
+ // operators who want to type every DNA field by hand.
16
+ type CreateMode = 'list' | 'wizard' | 'manual'
17
+ const createMode = ref<CreateMode>('list')
18
+ const showForm = computed(() => createMode.value === 'manual')
19
+
20
+ function startWizard() {
21
+ createMode.value = 'wizard'
22
+ }
23
+
24
+ function startManual() {
25
+ createMode.value = 'manual'
26
+ }
27
+
28
+ function cancelCreation() {
29
+ createMode.value = 'list'
30
+ }
31
+
32
+ async function onWizardComplete() {
33
+ createMode.value = 'list'
34
+ await refresh()
35
+ }
14
36
 
15
37
  // --- Form state ---
16
38
  function defaultForm() {
@@ -216,11 +238,29 @@ function discColor(disc: string): string {
216
238
 
217
239
  <template #right>
218
240
  <UButton
219
- :label="showForm ? 'Cancel' : 'New Persona'"
220
- :icon="showForm ? 'i-lucide-x' : 'i-lucide-plus'"
221
- :variant="showForm ? 'ghost' : 'solid'"
241
+ v-if="createMode === 'list'"
242
+ label="AI Builder"
243
+ icon="i-lucide-sparkles"
244
+ color="primary"
245
+ size="sm"
246
+ @click="startWizard"
247
+ />
248
+ <UButton
249
+ v-if="createMode === 'list'"
250
+ label="Manual"
251
+ icon="i-lucide-plus"
252
+ variant="outline"
253
+ size="sm"
254
+ class="ml-2"
255
+ @click="startManual"
256
+ />
257
+ <UButton
258
+ v-else
259
+ label="Back to list"
260
+ icon="i-lucide-arrow-left"
261
+ variant="ghost"
222
262
  size="sm"
223
- @click="showForm = !showForm"
263
+ @click="cancelCreation"
224
264
  />
225
265
  </template>
226
266
  </UDashboardNavbar>
@@ -242,7 +282,15 @@ function discColor(disc: string): string {
242
282
 
243
283
  <!-- Content -->
244
284
  <template v-else>
245
- <!-- Create Persona Form -->
285
+ <!-- PR62: AI Persona Wizard -->
286
+ <PersonaWizard
287
+ v-if="createMode === 'wizard'"
288
+ class="mb-8"
289
+ @completed="onWizardComplete"
290
+ @cancelled="cancelCreation"
291
+ />
292
+
293
+ <!-- Manual create form (legacy / fallback) -->
246
294
  <UCard v-if="showForm" class="mb-8">
247
295
  <form @submit.prevent="createPersona" class="space-y-8 p-2">
248
296
  <!-- Identity -->
@@ -94,20 +94,12 @@ const columns: TableColumn<Task>[] = [
94
94
  </template>
95
95
 
96
96
  <template #body>
97
- <!-- Loading -->
98
- <div v-if="status === 'pending'" class="flex items-center justify-center py-12">
99
- <UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-muted" />
100
- </div>
101
-
102
- <!-- Error -->
103
- <div v-else-if="error" class="flex flex-col items-center justify-center gap-4 py-12" role="alert">
104
- <UIcon name="i-lucide-alert-triangle" class="size-12 text-red-500" />
105
- <p class="text-sm text-muted">Failed to load tasks.</p>
106
- <UButton label="Retry" variant="outline" color="primary" icon="i-lucide-refresh-cw" @click="refresh()" />
107
- </div>
108
-
109
- <!-- Content -->
110
- <template v-else>
97
+ <DashboardState
98
+ :status="status"
99
+ :error="error"
100
+ loading-label="Loading tasks"
101
+ :on-retry="() => refresh()"
102
+ >
111
103
  <!-- Summary Cards -->
112
104
  <div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
113
105
  <div class="rounded-lg border border-default p-4 text-center">
@@ -197,7 +189,7 @@ const columns: TableColumn<Task>[] = [
197
189
  </template>
198
190
  </UTable>
199
191
  </div>
200
- </template>
192
+ </DashboardState>
201
193
  </template>
202
194
  </UDashboardPanel>
203
195
  </template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "2.78.0",
3
+ "version": "2.80.0",
4
4
  "description": "The Operating System for AI Agent Teams",
5
5
  "type": "module",
6
6
  "bin": {
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arkaos-core"
3
- version = "2.78.0"
3
+ version = "2.80.0"
4
4
  description = "Core engine for ArkaOS — The Operating System for AI Agent Teams"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}