arkaos 2.95.0 → 2.97.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.
@@ -1,599 +0,0 @@
1
- <script setup lang="ts">
2
- // PR74 v2.92.0 — Persona detail + edit drawer.
3
- //
4
- // Click a persona card on the list → this drawer opens with every
5
- // field visible. Toggle to Edit mode to mutate any field, then save
6
- // via PUT /api/personas/{id} (writes to both JSON store + Obsidian).
7
-
8
- import type { Persona } from '~/types'
9
-
10
- interface DetailResponse extends Persona {
11
- _source_store?: 'obsidian' | 'json'
12
- _obsidian_path?: string
13
- }
14
-
15
- const props = defineProps<{
16
- modelValue: boolean
17
- personaId: string | null
18
- }>()
19
-
20
- const emit = defineEmits<{
21
- (e: 'update:modelValue', value: boolean): void
22
- (e: 'saved', persona: Persona): void
23
- (e: 'deleted', personaId: string): void
24
- }>()
25
-
26
- const { apiBase } = useApi()
27
- const toast = useToast()
28
- const confirmDialog = useConfirmDialog()
29
-
30
- const detail = ref<DetailResponse | null>(null)
31
- const editing = ref(false)
32
- const draft = ref<Persona | null>(null)
33
- const saving = ref(false)
34
- const deleting = ref(false)
35
- const loading = ref(false)
36
- const loadError = ref<string | null>(null)
37
-
38
- watch(
39
- () => [props.modelValue, props.personaId] as const,
40
- async ([open, id]) => {
41
- if (!open || !id) {
42
- detail.value = null
43
- editing.value = false
44
- draft.value = null
45
- loadError.value = null
46
- return
47
- }
48
- await loadDetail(id)
49
- },
50
- )
51
-
52
- async function loadDetail(id: string) {
53
- loading.value = true
54
- loadError.value = null
55
- try {
56
- const data = await $fetch<DetailResponse | { error: string }>(
57
- `${apiBase}/api/personas/${id}`,
58
- )
59
- if ('error' in data && data.error) {
60
- loadError.value = data.error
61
- detail.value = null
62
- } else {
63
- detail.value = data as DetailResponse
64
- }
65
- } catch (err) {
66
- loadError.value = err instanceof Error ? err.message : 'unknown error'
67
- } finally {
68
- loading.value = false
69
- }
70
- }
71
-
72
- function startEdit() {
73
- if (!detail.value) return
74
- draft.value = JSON.parse(JSON.stringify(detail.value)) as Persona
75
- editing.value = true
76
- }
77
-
78
- function cancelEdit() {
79
- draft.value = null
80
- editing.value = false
81
- }
82
-
83
- async function saveEdit() {
84
- if (!draft.value || !props.personaId) return
85
- saving.value = true
86
- try {
87
- const res = await $fetch<{
88
- id: string
89
- updated: boolean
90
- json_written: boolean
91
- obsidian_path: string | null
92
- error?: string
93
- }>(`${apiBase}/api/personas/${props.personaId}`, {
94
- method: 'PUT',
95
- body: draft.value,
96
- })
97
- if (res.error) throw new Error(res.error)
98
- toast.add({
99
- title: 'Persona saved',
100
- description: res.obsidian_path
101
- ? `Wrote ${res.obsidian_path.split('/').slice(-2).join('/')}`
102
- : 'Saved to JSON store',
103
- color: 'success',
104
- })
105
- emit('saved', draft.value)
106
- detail.value = { ...detail.value, ...draft.value } as DetailResponse
107
- editing.value = false
108
- } catch (err) {
109
- toast.add({
110
- title: 'Save failed',
111
- description: err instanceof Error ? err.message : 'unknown error',
112
- color: 'error',
113
- })
114
- } finally {
115
- saving.value = false
116
- }
117
- }
118
-
119
- async function deletePersona() {
120
- if (!props.personaId) return
121
- const ok = await confirmDialog({
122
- title: `Delete persona "${detail.value?.name ?? 'Unknown'}"?`,
123
- description:
124
- 'Removes it from the JSON store. The Obsidian file (if any) is '
125
- + 'left in place — delete manually from Obsidian if you want it gone.',
126
- confirmLabel: 'Delete persona',
127
- variant: 'danger',
128
- })
129
- if (!ok) return
130
- deleting.value = true
131
- try {
132
- await $fetch(`${apiBase}/api/personas/${props.personaId}`, {
133
- method: 'DELETE',
134
- })
135
- toast.add({
136
- title: 'Persona deleted',
137
- description: detail.value?.name ?? '',
138
- color: 'success',
139
- })
140
- emit('deleted', props.personaId)
141
- emit('update:modelValue', false)
142
- } catch (err) {
143
- toast.add({
144
- title: 'Delete failed',
145
- description: err instanceof Error ? err.message : 'unknown error',
146
- color: 'error',
147
- })
148
- } finally {
149
- deleting.value = false
150
- }
151
- }
152
-
153
- async function closeDrawer() {
154
- if (editing.value && !saving.value) {
155
- const ok = await confirmDialog({
156
- title: 'Discard unsaved edits?',
157
- description: 'Any changes you made will be lost.',
158
- confirmLabel: 'Discard',
159
- cancelLabel: 'Keep editing',
160
- variant: 'danger',
161
- })
162
- if (!ok) return
163
- }
164
- cancelEdit()
165
- emit('update:modelValue', false)
166
- }
167
-
168
- function listToCsv(list: string[] | undefined): string {
169
- return (list ?? []).join(', ')
170
- }
171
-
172
- function csvToList(value: string): string[] {
173
- return value.split(',').map((s) => s.trim()).filter(Boolean)
174
- }
175
-
176
- const mbtiOptions = [
177
- 'INTJ', 'INTP', 'ENTJ', 'ENTP',
178
- 'INFJ', 'INFP', 'ENFJ', 'ENFP',
179
- 'ISTJ', 'ISFJ', 'ESTJ', 'ESFJ',
180
- 'ISTP', 'ISFP', 'ESTP', 'ESFP',
181
- ].map((t) => ({ label: t, value: t }))
182
-
183
- const discOptions = [
184
- { label: 'D — Dominance', value: 'D' },
185
- { label: 'I — Influence', value: 'I' },
186
- { label: 'S — Steadiness', value: 'S' },
187
- { label: 'C — Conscientiousness', value: 'C' },
188
- ]
189
-
190
- const vocabOptions = [
191
- { label: 'Lay (no jargon)', value: 'lay' },
192
- { label: 'Specialist (industry terms)', value: 'specialist' },
193
- { label: 'Expert (research-level)', value: 'expert' },
194
- ]
195
-
196
- // PR77 v2.95.0 — hero gradient by MBTI grouping + initials avatar
197
- // + reverse-usage stat (how many agents link this persona).
198
-
199
- const { fetchApi } = useApi()
200
- const { data: usageData } = fetchApi<{
201
- by_persona: Record<string, { agent_count: number, agent_ids: string[] }>
202
- }>('/api/personas/usage')
203
-
204
- const linkedAgentCount = computed(() => {
205
- if (!detail.value?.id) return 0
206
- return usageData.value?.by_persona?.[detail.value.id]?.agent_count ?? 0
207
- })
208
- const linkedAgentIds = computed(() => {
209
- if (!detail.value?.id) return []
210
- return usageData.value?.by_persona?.[detail.value.id]?.agent_ids ?? []
211
- })
212
-
213
- function heroInitials(name: string | undefined): string {
214
- if (!name) return '·'
215
- const parts = name.trim().split(/\s+/)
216
- if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
217
- return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
218
- }
219
-
220
- function mbtiGradientClass(mbti: string | undefined): string {
221
- if (!mbti) return 'bg-gradient-to-br from-muted/20 to-muted/5'
222
- const code = mbti.toUpperCase()
223
- if (['INTJ', 'INTP', 'ENTJ', 'ENTP'].includes(code))
224
- return 'bg-gradient-to-br from-blue-500/30 to-indigo-600/10'
225
- if (['INFJ', 'INFP', 'ENFJ', 'ENFP'].includes(code))
226
- return 'bg-gradient-to-br from-emerald-500/30 to-teal-600/10'
227
- if (['ISTJ', 'ISFJ', 'ESTJ', 'ESFJ'].includes(code))
228
- return 'bg-gradient-to-br from-amber-500/30 to-orange-600/10'
229
- if (['ISTP', 'ISFP', 'ESTP', 'ESFP'].includes(code))
230
- return 'bg-gradient-to-br from-rose-500/30 to-pink-600/10'
231
- return 'bg-gradient-to-br from-primary/20 to-primary/5'
232
- }
233
- </script>
234
-
235
- <template>
236
- <USlideover
237
- :open="modelValue"
238
- :ui="{ content: 'max-w-2xl w-full' }"
239
- @update:open="(v) => v ? null : closeDrawer()"
240
- >
241
- <template #content>
242
- <UCard
243
- :ui="{
244
- root: 'h-full flex flex-col rounded-none',
245
- body: 'flex-1 overflow-y-auto',
246
- }"
247
- >
248
- <template #header>
249
- <div
250
- class="-m-4 mb-0 p-5 rounded-t-lg"
251
- :class="mbtiGradientClass(detail?.mbti)"
252
- >
253
- <div class="flex items-start justify-between gap-3">
254
- <div class="flex items-start gap-4 min-w-0 flex-1">
255
- <div class="shrink-0 size-14 rounded-xl bg-default/80 border border-default flex items-center justify-center shadow-md backdrop-blur-sm">
256
- <span class="text-lg font-bold tracking-tight text-highlighted">
257
- {{ heroInitials(detail?.name) }}
258
- </span>
259
- </div>
260
- <div class="min-w-0 flex-1">
261
- <h2 class="text-2xl font-bold truncate text-highlighted">
262
- {{ detail?.name ?? 'Persona' }}
263
- </h2>
264
- <p v-if="detail?.title" class="text-sm text-muted truncate mt-0.5">
265
- {{ detail.title }}
266
- </p>
267
- <div class="flex items-center gap-2 mt-2 flex-wrap">
268
- <UBadge
269
- v-if="detail?._source_store === 'obsidian'"
270
- label="From Obsidian"
271
- icon="i-lucide-file-text"
272
- color="primary"
273
- variant="subtle"
274
- size="xs"
275
- />
276
- <UBadge
277
- v-else-if="detail?._source_store === 'json'"
278
- label="JSON store"
279
- variant="outline"
280
- size="xs"
281
- />
282
- <UBadge
283
- v-if="detail?.mbti"
284
- :label="detail.mbti"
285
- variant="soft"
286
- size="xs"
287
- />
288
- <UBadge
289
- v-if="linkedAgentCount > 0"
290
- :label="`${linkedAgentCount} agent${linkedAgentCount === 1 ? '' : 's'}`"
291
- color="primary"
292
- variant="subtle"
293
- size="xs"
294
- />
295
- </div>
296
- <p
297
- v-if="detail?._obsidian_path"
298
- class="text-[10px] text-muted/70 font-mono truncate mt-2"
299
- :title="detail._obsidian_path"
300
- >
301
- {{ detail._obsidian_path.split('/').slice(-2).join('/') }}
302
- </p>
303
- </div>
304
- </div>
305
- <div class="flex items-center gap-1 shrink-0">
306
- <UButton
307
- v-if="!editing"
308
- icon="i-lucide-pencil"
309
- variant="ghost"
310
- size="sm"
311
- aria-label="Edit persona"
312
- @click="startEdit"
313
- />
314
- <UButton
315
- v-if="!editing"
316
- icon="i-lucide-trash-2"
317
- color="error"
318
- variant="ghost"
319
- size="sm"
320
- :loading="deleting"
321
- aria-label="Delete persona"
322
- @click="deletePersona"
323
- />
324
- <UButton
325
- icon="i-lucide-x"
326
- variant="ghost"
327
- size="sm"
328
- aria-label="Close"
329
- @click="closeDrawer"
330
- />
331
- </div>
332
- </div>
333
- </div>
334
- </template>
335
-
336
- <div v-if="loading" class="flex items-center justify-center py-12">
337
- <UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-muted" />
338
- </div>
339
-
340
- <div v-else-if="loadError" class="flex flex-col items-center justify-center gap-3 py-12">
341
- <UIcon name="i-lucide-alert-triangle" class="size-10 text-red-500" />
342
- <p class="text-sm text-muted">{{ loadError }}</p>
343
- </div>
344
-
345
- <div v-else-if="detail && !editing" class="space-y-6">
346
- <p v-if="detail.tagline" class="text-base italic text-muted">
347
- "{{ detail.tagline }}"
348
- </p>
349
-
350
- <section v-if="detail.title || detail.source">
351
- <h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">Identity</h3>
352
- <dl class="grid grid-cols-3 gap-2 text-sm">
353
- <dt class="text-muted">Title</dt>
354
- <dd class="col-span-2">{{ detail.title || '—' }}</dd>
355
- <dt class="text-muted">Source</dt>
356
- <dd class="col-span-2 font-mono text-xs">{{ detail.source || '—' }}</dd>
357
- </dl>
358
- </section>
359
-
360
- <section>
361
- <h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">Behavioural DNA</h3>
362
- <dl class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
363
- <dt class="text-muted">MBTI</dt>
364
- <dd>{{ detail.mbti || '—' }}</dd>
365
- <dt class="text-muted">DISC</dt>
366
- <dd>
367
- {{ detail.disc?.primary || '—' }}{{ detail.disc?.secondary ? `/${detail.disc.secondary}` : '' }}
368
- </dd>
369
- <dt class="text-muted">Enneagram</dt>
370
- <dd>
371
- {{ detail.enneagram?.type ?? '—' }}w{{ detail.enneagram?.wing ?? '?' }}
372
- </dd>
373
- </dl>
374
- <div class="mt-3 space-y-1.5">
375
- <div
376
- v-for="trait in ([
377
- ['Openness', detail.big_five?.openness ?? 0],
378
- ['Conscientiousness', detail.big_five?.conscientiousness ?? 0],
379
- ['Extraversion', detail.big_five?.extraversion ?? 0],
380
- ['Agreeableness', detail.big_five?.agreeableness ?? 0],
381
- ['Neuroticism', detail.big_five?.neuroticism ?? 0],
382
- ] as Array<[string, number]>)"
383
- :key="trait[0]"
384
- class="flex items-center gap-3"
385
- >
386
- <span class="text-xs text-muted w-36 shrink-0">{{ trait[0] }}</span>
387
- <div class="flex-1 h-2 rounded-full bg-muted/15 overflow-hidden">
388
- <div class="h-2 rounded-full bg-primary" :style="{ width: `${trait[1]}%` }" />
389
- </div>
390
- <span class="text-xs font-mono w-10 text-right">{{ trait[1] }}</span>
391
- </div>
392
- </div>
393
- </section>
394
-
395
- <section v-if="detail.mental_models?.length">
396
- <h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">
397
- Mental models ({{ detail.mental_models.length }})
398
- </h3>
399
- <div class="flex flex-wrap gap-1.5">
400
- <UBadge
401
- v-for="m in detail.mental_models"
402
- :key="m"
403
- :label="m"
404
- variant="outline"
405
- size="xs"
406
- />
407
- </div>
408
- </section>
409
-
410
- <section v-if="detail.expertise_domains?.length">
411
- <h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">
412
- Expertise ({{ detail.expertise_domains.length }})
413
- </h3>
414
- <div class="flex flex-wrap gap-1.5">
415
- <UBadge
416
- v-for="e in detail.expertise_domains"
417
- :key="e"
418
- :label="e"
419
- variant="soft"
420
- size="xs"
421
- />
422
- </div>
423
- </section>
424
-
425
- <section v-if="detail.frameworks?.length">
426
- <h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">
427
- Frameworks ({{ detail.frameworks.length }})
428
- </h3>
429
- <div class="flex flex-wrap gap-1.5">
430
- <UBadge
431
- v-for="f in detail.frameworks"
432
- :key="f"
433
- :label="f"
434
- variant="outline"
435
- size="xs"
436
- />
437
- </div>
438
- </section>
439
-
440
- <section v-if="detail.key_quotes?.length">
441
- <h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">
442
- Key quotes ({{ detail.key_quotes.length }})
443
- </h3>
444
- <ul class="space-y-2">
445
- <li
446
- v-for="q in detail.key_quotes"
447
- :key="q"
448
- class="text-sm italic text-muted border-l-2 border-primary/30 pl-3"
449
- >
450
- "{{ q }}"
451
- </li>
452
- </ul>
453
- </section>
454
-
455
- <section v-if="detail.communication">
456
- <h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">Communication</h3>
457
- <dl class="grid grid-cols-3 gap-2 text-sm">
458
- <dt class="text-muted">Tone</dt>
459
- <dd class="col-span-2">{{ detail.communication.tone || '—' }}</dd>
460
- <dt class="text-muted">Vocabulary</dt>
461
- <dd class="col-span-2">{{ detail.communication.vocabulary_level || '—' }}</dd>
462
- </dl>
463
- </section>
464
-
465
- <!-- PR77 v2.95.0 — linked agents (reverse from /api/personas/usage) -->
466
- <section v-if="linkedAgentCount > 0">
467
- <h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">
468
- Linked to {{ linkedAgentCount }} agent{{ linkedAgentCount === 1 ? '' : 's' }}
469
- </h3>
470
- <div class="flex flex-wrap gap-1.5">
471
- <NuxtLink
472
- v-for="aid in linkedAgentIds"
473
- :key="aid"
474
- :to="`/agents/${aid}`"
475
- class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 text-xs font-mono hover:border-primary/40 hover:text-primary transition-colors"
476
- >
477
- <UIcon name="i-lucide-arrow-right" class="size-3" />
478
- {{ aid }}
479
- </NuxtLink>
480
- </div>
481
- </section>
482
- </div>
483
-
484
- <div v-else-if="draft" class="space-y-5">
485
- <section class="space-y-3">
486
- <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Identity</h3>
487
- <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
488
- <UFormField label="Name" required>
489
- <UInput v-model="draft.name" class="w-full" />
490
- </UFormField>
491
- <UFormField label="Title">
492
- <UInput v-model="draft.title" class="w-full" />
493
- </UFormField>
494
- <UFormField label="Source">
495
- <UInput v-model="draft.source" class="w-full" />
496
- </UFormField>
497
- <UFormField label="Tagline">
498
- <UInput v-model="draft.tagline" class="w-full" />
499
- </UFormField>
500
- </div>
501
- </section>
502
-
503
- <section class="space-y-3">
504
- <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Behavioural DNA</h3>
505
- <div class="grid grid-cols-2 md:grid-cols-4 gap-3">
506
- <UFormField label="MBTI">
507
- <USelect v-model="draft.mbti" :items="mbtiOptions" class="w-full" />
508
- </UFormField>
509
- <UFormField label="DISC primary">
510
- <USelect v-model="draft.disc.primary" :items="discOptions" class="w-full" />
511
- </UFormField>
512
- <UFormField label="Enneagram">
513
- <UInput v-model.number="draft.enneagram.type" type="number" :min="1" :max="9" class="w-full" />
514
- </UFormField>
515
- <UFormField label="Wing">
516
- <UInput v-model.number="draft.enneagram.wing" type="number" :min="1" :max="9" class="w-full" />
517
- </UFormField>
518
- </div>
519
- <div class="space-y-2">
520
- <div
521
- v-for="key in (['openness', 'conscientiousness', 'extraversion', 'agreeableness', 'neuroticism'] as const)"
522
- :key="key"
523
- class="flex items-center gap-3"
524
- >
525
- <label class="text-xs text-muted w-36 shrink-0 capitalize">{{ key }}</label>
526
- <UInput
527
- v-model.number="draft.big_five[key]"
528
- type="number"
529
- :min="0"
530
- :max="100"
531
- class="w-20"
532
- />
533
- <input
534
- v-model.number="draft.big_five[key]"
535
- type="range"
536
- :min="0"
537
- :max="100"
538
- class="flex-1"
539
- />
540
- </div>
541
- </div>
542
- </section>
543
-
544
- <section class="space-y-3">
545
- <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Knowledge</h3>
546
- <UFormField label="Mental models" help="comma-separated">
547
- <UInput
548
- :model-value="listToCsv(draft.mental_models)"
549
- @update:model-value="(v: string) => draft && (draft.mental_models = csvToList(v))"
550
- class="w-full"
551
- />
552
- </UFormField>
553
- <UFormField label="Expertise domains" help="comma-separated">
554
- <UInput
555
- :model-value="listToCsv(draft.expertise_domains)"
556
- @update:model-value="(v: string) => draft && (draft.expertise_domains = csvToList(v))"
557
- class="w-full"
558
- />
559
- </UFormField>
560
- <UFormField label="Frameworks" help="comma-separated">
561
- <UInput
562
- :model-value="listToCsv(draft.frameworks)"
563
- @update:model-value="(v: string) => draft && (draft.frameworks = csvToList(v))"
564
- class="w-full"
565
- />
566
- </UFormField>
567
- </section>
568
-
569
- <section class="space-y-3">
570
- <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Communication</h3>
571
- <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
572
- <UFormField label="Tone">
573
- <UInput v-model="draft.communication.tone" class="w-full" />
574
- </UFormField>
575
- <UFormField label="Vocabulary level">
576
- <USelect v-model="draft.communication.vocabulary_level" :items="vocabOptions" class="w-full" />
577
- </UFormField>
578
- </div>
579
- </section>
580
- </div>
581
-
582
- <template #footer>
583
- <div v-if="editing" class="flex justify-end gap-2">
584
- <UButton label="Cancel" variant="ghost" :disabled="saving" @click="cancelEdit" />
585
- <UButton
586
- label="Save"
587
- icon="i-lucide-check"
588
- :loading="saving"
589
- @click="saveEdit"
590
- />
591
- </div>
592
- <p v-else class="text-xs text-muted text-right">
593
- Click ✏️ to edit. Saves to JSON store + Obsidian vault when configured.
594
- </p>
595
- </template>
596
- </UCard>
597
- </template>
598
- </USlideover>
599
- </template>