feat: add TraitsTab component for managing agent traits
Some checks failed
CI / build-and-test (push) Failing after -32m5s
Some checks failed
CI / build-and-test (push) Failing after -32m5s
- Implemented TraitsTab.svelte to handle CRUD operations for agent traits. - Integrated grid for displaying traits with context menu actions for add, edit, and delete. - Added trait instruction editing functionality with a dedicated editor. - Updated AdminShell to include PersonasPage for navigation. - Enhanced AppSidebar with a new entry for Personas. - Extended ShellPage type to include 'personas'. - Defined new types for AgentPersona, AgentPart, and AgentTrait in types.ts.
This commit is contained in:
351
ui/src/components/personas/PartsTab.svelte
Normal file
351
ui/src/components/personas/PartsTab.svelte
Normal file
@@ -0,0 +1,351 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ErrorBoundary,
|
||||
FormerResolveSpecAPI,
|
||||
GridlerFull,
|
||||
NativeSelectCtrl,
|
||||
TextInputCtrl,
|
||||
type GridlerColumn,
|
||||
type GridlerContextMenuItem
|
||||
} from '@warkypublic/svelix';
|
||||
import { adminGridTheme } from '../../gridTheme';
|
||||
import { GlobalStateStore } from '../../shellState';
|
||||
import type { AgentPart } from '../../types';
|
||||
import FormerShell from '../shared/FormerShell.svelte';
|
||||
import ContentEditorField from '../shared/ContentEditorField.svelte';
|
||||
|
||||
type PartForm = {
|
||||
id?: string;
|
||||
name: string;
|
||||
part_type: string;
|
||||
description: string;
|
||||
summary: string;
|
||||
content: string;
|
||||
tags: string;
|
||||
};
|
||||
|
||||
const PART_TYPES = [
|
||||
'system', 'agent', 'soul', 'identity', 'skill', 'specialization',
|
||||
'tone', 'goal', 'context', 'protocol', 'backstory', 'motivation',
|
||||
'voice', 'archetype', 'flaw', 'relationship'
|
||||
];
|
||||
|
||||
const PRIMARY_KEY = 'id';
|
||||
const authToken = GlobalStateStore.getState().session.authToken ?? '';
|
||||
|
||||
let selectedPart = $state<AgentPart | null>(null);
|
||||
let gridTotal = $state<number | null>(null);
|
||||
let formOpened = $state(false);
|
||||
let formRequest = $state<'insert' | 'update' | 'delete'>('insert');
|
||||
let formValues = $state<PartForm>({ name: '', part_type: 'agent', description: '', summary: '', content: '', tags: '' });
|
||||
let contentEditorOpened = $state(false);
|
||||
let contentEditorValues = $state<{ id?: string; content: string }>({ content: '' });
|
||||
let contextRow = $state<Record<string, unknown> | null>(null);
|
||||
let refreshKey = $state(0);
|
||||
|
||||
const apiCall = $derived(FormerResolveSpecAPI({
|
||||
authToken,
|
||||
url: '/api/rs/public/agent_parts'
|
||||
}));
|
||||
|
||||
const dataSourceOptions = {
|
||||
url: '/api/rs',
|
||||
authToken,
|
||||
schema: 'public',
|
||||
entity: 'agent_parts',
|
||||
uniqueID: PRIMARY_KEY,
|
||||
hotfields: [PRIMARY_KEY],
|
||||
sort: [{ column: 'part_type', direction: 'asc' }, { column: 'name', direction: 'asc' }]
|
||||
} as unknown as { url: string; authToken?: string; schema: string; entity: string; uniqueID: string; hotfields: string[] };
|
||||
|
||||
const columns: GridlerColumn[] = [
|
||||
{ id: 'name', title: 'Name', dataKey: 'name', width: 220 },
|
||||
{ id: 'part_type', title: 'Type', dataKey: 'part_type', width: 130 },
|
||||
{ id: 'description', title: 'Description', dataKey: 'description', width: 260 },
|
||||
{ id: 'summary', title: 'Summary', dataKey: 'summary', width: 280 },
|
||||
{ id: 'tags', title: 'Tags', dataKey: 'tags', width: 160 },
|
||||
{ id: 'updated_at', title: 'Updated', dataKey: 'updated_at', width: 160, format: 'datetime' }
|
||||
];
|
||||
|
||||
const menuItems: GridlerContextMenuItem[] = [
|
||||
{ id: 'add', label: 'Add' },
|
||||
{ id: 'edit', label: 'Edit' },
|
||||
{ id: 'edit_content', label: 'Edit Content' },
|
||||
{ id: 'delete', label: 'Delete' }
|
||||
];
|
||||
|
||||
function normalizeTags(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map((t) => String(t).trim()).filter(Boolean);
|
||||
if (typeof value !== 'string' || !value.trim()) return [];
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
||||
return trimmed.slice(1, -1).split(',').map((t) => t.trim().replace(/^"(.*)"$/, '$1')).filter(Boolean);
|
||||
}
|
||||
return trimmed.split(',').map((t) => t.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizePart(row: Record<string, unknown>): AgentPart {
|
||||
return {
|
||||
id: String(row.id ?? ''),
|
||||
name: String(row.name ?? ''),
|
||||
part_type: String(row.part_type ?? ''),
|
||||
description: String(row.description ?? ''),
|
||||
summary: String(row.summary ?? ''),
|
||||
content: String(row.content ?? ''),
|
||||
tags: normalizeTags(row.tags),
|
||||
created_at: String(row.created_at ?? ''),
|
||||
updated_at: String(row.updated_at ?? '')
|
||||
};
|
||||
}
|
||||
|
||||
function toForm(p: AgentPart): PartForm {
|
||||
return { id: p.id, name: p.name, part_type: p.part_type, description: p.description, summary: p.summary, content: p.content, tags: p.tags.join(', ') };
|
||||
}
|
||||
|
||||
function normalizeForFormer(data: Record<string, unknown>): PartForm {
|
||||
return {
|
||||
id: data.id != null ? String(data.id) : undefined,
|
||||
name: String(data.name ?? ''),
|
||||
part_type: String(data.part_type ?? 'agent'),
|
||||
description: String(data.description ?? ''),
|
||||
summary: String(data.summary ?? ''),
|
||||
content: String(data.content ?? ''),
|
||||
tags: normalizeTags(data.tags).join(', ')
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeForm(data: PartForm): Record<string, unknown> {
|
||||
return {
|
||||
name: data.name.trim(),
|
||||
part_type: data.part_type,
|
||||
description: data.description.trim(),
|
||||
summary: data.summary.trim(),
|
||||
content: data.content,
|
||||
tags: data.tags.split(',').map((t) => t.trim()).filter(Boolean)
|
||||
};
|
||||
}
|
||||
|
||||
function onRowClick(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
selectedPart = rowData ? normalizePart(rowData) : null;
|
||||
}
|
||||
|
||||
function onRowContextMenu(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
contextRow = rowData ?? null;
|
||||
}
|
||||
|
||||
async function onMenuItemSelect(item: GridlerContextMenuItem) {
|
||||
if (item.id === 'add') {
|
||||
formValues = { name: '', part_type: 'agent', description: '', summary: '', content: '', tags: '' };
|
||||
formRequest = 'insert';
|
||||
formOpened = true;
|
||||
return;
|
||||
}
|
||||
if (!contextRow) return;
|
||||
const part = normalizePart(contextRow);
|
||||
if (item.id === 'edit_content') {
|
||||
selectedPart = part;
|
||||
contentEditorValues = { id: part.id, content: part.content };
|
||||
contentEditorOpened = true;
|
||||
return;
|
||||
}
|
||||
formValues = toForm(part);
|
||||
formRequest = item.id === 'delete' ? 'delete' : 'update';
|
||||
formOpened = true;
|
||||
}
|
||||
|
||||
function onRowDblClick(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
if (!rowData) return;
|
||||
contextRow = rowData;
|
||||
void onMenuItemSelect({ id: 'edit', label: 'Edit' });
|
||||
}
|
||||
|
||||
function onGridEvent(type: string, _i?: unknown, _c?: unknown, _co?: unknown, detail?: Record<string, unknown>) {
|
||||
if (type !== 'page_loaded' && type !== 'load') return;
|
||||
if (typeof detail?.total === 'number') gridTotal = detail.total;
|
||||
}
|
||||
|
||||
async function handleSaved() {
|
||||
formOpened = false;
|
||||
if (contextRow?.[PRIMARY_KEY]) {
|
||||
const data = await apiCall('read', 'update', undefined, String(contextRow[PRIMARY_KEY])) as Record<string, unknown>;
|
||||
selectedPart = normalizePart(data);
|
||||
}
|
||||
refreshKey += 1;
|
||||
}
|
||||
|
||||
async function handleContentSaved() {
|
||||
contentEditorOpened = false;
|
||||
if (contentEditorValues.id) {
|
||||
const data = await apiCall('read', 'update', undefined, contentEditorValues.id) as Record<string, unknown>;
|
||||
selectedPart = normalizePart(data);
|
||||
}
|
||||
refreshKey += 1;
|
||||
}
|
||||
|
||||
function formatDate(v?: string) {
|
||||
return v ? new Date(v).toLocaleString() : '—';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4 w-full">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<p class="text-sm text-slate-400">
|
||||
{#if gridTotal === null}Server-backed grid{:else}{gridTotal} part{gridTotal !== 1 ? 's' : ''}{/if}
|
||||
</p>
|
||||
<button
|
||||
class="rounded-xl border border-cyan-300/30 bg-cyan-400/10 px-4 py-2 text-sm font-medium text-cyan-100 transition hover:bg-cyan-400/20"
|
||||
onclick={() => { formValues = { name: '', part_type: 'agent', description: '', summary: '', content: '', tags: '' }; formRequest = 'insert'; formOpened = true; }}
|
||||
>New Part</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
|
||||
{#key refreshKey}
|
||||
<ErrorBoundary namespace="PartsGridler">
|
||||
<GridlerFull
|
||||
{columns}
|
||||
theme={adminGridTheme}
|
||||
rowMarkers="number"
|
||||
height={400}
|
||||
width="100%"
|
||||
pageSize={40}
|
||||
dataSource="resolvespec"
|
||||
dataSourceOptions={dataSourceOptions}
|
||||
serverSideSearch={true}
|
||||
searchColumns={['name', 'part_type', 'description', 'summary']}
|
||||
{menuItems}
|
||||
{onGridEvent}
|
||||
{onRowClick}
|
||||
{onRowDblClick}
|
||||
{onRowContextMenu}
|
||||
{onMenuItemSelect}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<aside class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold text-white">Part Inspector</h3>
|
||||
{#if selectedPart}
|
||||
<button
|
||||
class="text-xs text-cyan-300 hover:text-cyan-200"
|
||||
onclick={() => { if (!selectedPart) return; contentEditorValues = { id: selectedPart.id, content: selectedPart.content }; contentEditorOpened = true; }}
|
||||
>Edit Content</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !selectedPart}
|
||||
<p class="mt-3 text-sm text-slate-500">Select a part row to inspect.</p>
|
||||
{:else}
|
||||
<div class="mt-3 space-y-3 text-sm text-slate-300">
|
||||
<div class="flex items-center gap-3">
|
||||
<p class="text-base font-semibold text-slate-100">{selectedPart.name}</p>
|
||||
<span class="rounded-md bg-cyan-400/10 px-2 py-0.5 text-xs text-cyan-300">{selectedPart.part_type}</span>
|
||||
</div>
|
||||
{#if selectedPart.description}
|
||||
<p><strong class="text-slate-100">Description:</strong> {selectedPart.description}</p>
|
||||
{/if}
|
||||
<p><strong class="text-slate-100">Updated:</strong> {formatDate(selectedPart.updated_at)}</p>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Tags</p>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
{#if selectedPart.tags.length}
|
||||
{#each selectedPart.tags as tag}
|
||||
<span class="rounded-md bg-white/10 px-2 py-0.5 text-xs text-slate-300">{tag}</span>
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="text-slate-500">No tags</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Summary</p>
|
||||
<pre class="mt-2 overflow-x-auto rounded-xl bg-slate-950/60 p-3 text-xs text-slate-300 whitespace-pre-wrap">{selectedPart.summary || '—'}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Content</p>
|
||||
<pre class="mt-2 overflow-x-auto rounded-xl bg-slate-950/60 p-3 text-xs text-slate-300 whitespace-pre-wrap">{selectedPart.content || '—'}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorBoundary namespace="PartContentEditor">
|
||||
<FormerShell
|
||||
bind:opened={contentEditorOpened}
|
||||
bind:values={contentEditorValues}
|
||||
request="update"
|
||||
title="Edit Part Content"
|
||||
uniqueKeyField={PRIMARY_KEY}
|
||||
width="min(96vw, 90rem)"
|
||||
onAPICall={apiCall}
|
||||
beforeSave={(data) => ({ content: data.content })}
|
||||
afterSave={handleContentSaved}
|
||||
onClose={() => { contentEditorOpened = false; }}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<ContentEditorField
|
||||
filename="part.md"
|
||||
value={state.values?.content ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, content: v })}
|
||||
/>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
|
||||
<ErrorBoundary namespace="PartsFormer">
|
||||
<FormerShell
|
||||
bind:opened={formOpened}
|
||||
bind:values={formValues}
|
||||
bind:request={formRequest}
|
||||
title={formRequest === 'insert' ? 'New Part' : formRequest === 'update' ? 'Edit Part' : 'Delete Part'}
|
||||
uniqueKeyField={PRIMARY_KEY}
|
||||
onAPICall={apiCall}
|
||||
afterGet={async (data) => normalizeForFormer(data as Record<string, unknown>)}
|
||||
beforeSave={normalizeForm}
|
||||
afterSave={handleSaved}
|
||||
onClose={() => { formOpened = false; }}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<TextInputCtrl
|
||||
label="Name"
|
||||
name="name"
|
||||
required
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.name ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, name: v })}
|
||||
/>
|
||||
<NativeSelectCtrl
|
||||
label="Part Type"
|
||||
name="part_type"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.part_type ?? 'agent'}
|
||||
options={PART_TYPES}
|
||||
onchange={(v) => state.setState('values', { ...state.values, part_type: v })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Description"
|
||||
name="description"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.description ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, description: v })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Tags"
|
||||
name="tags"
|
||||
placeholder="comma-separated"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.tags ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, tags: v })}
|
||||
/>
|
||||
<ContentEditorField
|
||||
filename="part-summary.md"
|
||||
value={state.values?.summary ?? ''}
|
||||
disabled={state.request === 'delete'}
|
||||
onchange={(v) => state.setState('values', { ...state.values, summary: v })}
|
||||
/>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
51
ui/src/components/personas/PersonasPage.svelte
Normal file
51
ui/src/components/personas/PersonasPage.svelte
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import PartsTab from './PartsTab.svelte';
|
||||
import PersonasTab from './PersonasTab.svelte';
|
||||
import TraitsTab from './TraitsTab.svelte';
|
||||
|
||||
type PersonaSection = 'personas' | 'parts' | 'traits';
|
||||
|
||||
const sections: { id: PersonaSection; label: string; description: string }[] = [
|
||||
{ id: 'personas', label: 'Personas', description: 'Inspect composed agent identities and compiled output.' },
|
||||
{ id: 'parts', label: 'Parts', description: 'Manage reusable behavior building blocks.' },
|
||||
{ id: 'traits', label: 'Traits', description: 'Curate atomic personality and behavior traits.' }
|
||||
];
|
||||
|
||||
let currentSection = $state<PersonaSection>('personas');
|
||||
</script>
|
||||
|
||||
<div class="space-y-5">
|
||||
<section class="rounded-3xl border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(34,211,238,0.18),_transparent_40%),linear-gradient(135deg,_rgba(15,23,42,0.96),_rgba(2,6,23,0.92))] p-6">
|
||||
<p class="text-xs uppercase tracking-[0.28em] text-cyan-300">Personas</p>
|
||||
<h2 class="mt-3 text-2xl font-semibold text-white">Shape how agents think, speak, and adapt.</h2>
|
||||
<p class="mt-2 max-w-3xl text-sm text-slate-300">
|
||||
Manage reusable parts and traits alongside the compiled personas they form. Select a persona to inspect its linked components and current arc state.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{#each sections as section}
|
||||
<button
|
||||
class={`rounded-2xl border px-4 py-3 text-left transition ${
|
||||
currentSection === section.id
|
||||
? 'border-cyan-300/30 bg-cyan-400/12 text-cyan-100'
|
||||
: 'border-white/10 bg-white/5 text-slate-300 hover:bg-white/10'
|
||||
}`}
|
||||
onclick={() => {
|
||||
currentSection = section.id;
|
||||
}}
|
||||
>
|
||||
<div class="text-sm font-semibold">{section.label}</div>
|
||||
<div class="mt-1 text-xs text-slate-400">{section.description}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if currentSection === 'personas'}
|
||||
<PersonasTab />
|
||||
{:else if currentSection === 'parts'}
|
||||
<PartsTab />
|
||||
{:else}
|
||||
<TraitsTab />
|
||||
{/if}
|
||||
</div>
|
||||
657
ui/src/components/personas/PersonasTab.svelte
Normal file
657
ui/src/components/personas/PersonasTab.svelte
Normal file
@@ -0,0 +1,657 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ErrorBoundary,
|
||||
FormerResolveSpecAPI,
|
||||
GridlerFull,
|
||||
TextInputCtrl,
|
||||
type GridlerColumn,
|
||||
type GridlerContextMenuItem
|
||||
} from '@warkypublic/svelix';
|
||||
import { adminGridTheme } from '../../gridTheme';
|
||||
import { GlobalStateStore } from '../../shellState';
|
||||
import type { AgentPersona } from '../../types';
|
||||
import FormerShell from '../shared/FormerShell.svelte';
|
||||
import ContentEditorField from '../shared/ContentEditorField.svelte';
|
||||
|
||||
type PersonaForm = {
|
||||
id?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
summary: string;
|
||||
detail: string;
|
||||
tags: string;
|
||||
};
|
||||
|
||||
type ManifestPart = {
|
||||
name: string;
|
||||
part_type: string;
|
||||
description: string;
|
||||
source: string;
|
||||
part_order: number;
|
||||
priority: number;
|
||||
};
|
||||
|
||||
type ManifestTrait = {
|
||||
name: string;
|
||||
trait_type: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type ManifestSkill = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type ManifestGuardrail = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
severity: string;
|
||||
};
|
||||
|
||||
type PersonaArcState = {
|
||||
arc_name: string;
|
||||
stage_name: string;
|
||||
stage_order: number;
|
||||
description: string;
|
||||
condition: string;
|
||||
};
|
||||
|
||||
type PersonaManifest = {
|
||||
parts: ManifestPart[];
|
||||
traits: ManifestTrait[];
|
||||
skills: ManifestSkill[];
|
||||
guardrails: ManifestGuardrail[];
|
||||
arc: PersonaArcState | null;
|
||||
};
|
||||
|
||||
function isDefined<T>(value: T | null): value is T {
|
||||
return value !== null;
|
||||
}
|
||||
|
||||
const PRIMARY_KEY = 'id';
|
||||
const authToken = GlobalStateStore.getState().session.authToken ?? '';
|
||||
|
||||
let selectedPersona = $state<AgentPersona | null>(null);
|
||||
let gridTotal = $state<number | null>(null);
|
||||
let formOpened = $state(false);
|
||||
let formRequest = $state<'insert' | 'update' | 'delete'>('insert');
|
||||
let formValues = $state<PersonaForm>({ name: '', description: '', summary: '', detail: '', tags: '' });
|
||||
let detailEditorOpened = $state(false);
|
||||
let detailEditorValues = $state<{ id?: string; detail: string }>({ detail: '' });
|
||||
let contextRow = $state<Record<string, unknown> | null>(null);
|
||||
let refreshKey = $state(0);
|
||||
let manifestLoading = $state(false);
|
||||
let manifestError = $state('');
|
||||
let manifest = $state<PersonaManifest | null>(null);
|
||||
|
||||
const apiCall = $derived(FormerResolveSpecAPI({
|
||||
authToken,
|
||||
url: '/api/rs/public/agent_personas'
|
||||
}));
|
||||
|
||||
const dataSourceOptions = {
|
||||
url: '/api/rs',
|
||||
authToken,
|
||||
schema: 'public',
|
||||
entity: 'agent_personas',
|
||||
uniqueID: PRIMARY_KEY,
|
||||
hotfields: [PRIMARY_KEY],
|
||||
sort: [{ column: 'created_at', direction: 'desc' }]
|
||||
} as unknown as { url: string; authToken?: string; schema: string; entity: string; uniqueID: string; hotfields: string[] };
|
||||
|
||||
const columns: GridlerColumn[] = [
|
||||
{ id: 'name', title: 'Name', dataKey: 'name', width: 220 },
|
||||
{ id: 'description', title: 'Description', dataKey: 'description', width: 280 },
|
||||
{ id: 'summary', title: 'Summary', dataKey: 'summary', width: 300 },
|
||||
{ id: 'tags', title: 'Tags', dataKey: 'tags', width: 180 },
|
||||
{ id: 'created_at', title: 'Created', dataKey: 'created_at', width: 160, format: 'datetime' },
|
||||
{ id: 'updated_at', title: 'Updated', dataKey: 'updated_at', width: 160, format: 'datetime' }
|
||||
];
|
||||
|
||||
const menuItems: GridlerContextMenuItem[] = [
|
||||
{ id: 'add', label: 'Add' },
|
||||
{ id: 'edit', label: 'Edit' },
|
||||
{ id: 'edit_detail', label: 'Edit Detail' },
|
||||
{ id: 'delete', label: 'Delete' }
|
||||
];
|
||||
|
||||
async function rsReadMany(entity: string, options?: Record<string, unknown>) {
|
||||
const res = await fetch(`/api/rs/public/${entity}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
operation: 'read',
|
||||
...(options ? { options } : {})
|
||||
})
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to load ${entity} (${res.status})`);
|
||||
const body = await res.json() as { success?: boolean; data?: unknown; error?: { message?: string } };
|
||||
if (!body.success) throw new Error(body.error?.message ?? `ResolveSpec request failed for ${entity}`);
|
||||
return Array.isArray(body.data) ? body.data as Record<string, unknown>[] : [];
|
||||
}
|
||||
|
||||
function normalizeTags(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map((t) => String(t).trim()).filter(Boolean);
|
||||
if (typeof value !== 'string' || !value.trim()) return [];
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
||||
return trimmed.slice(1, -1).split(',').map((t) => t.trim().replace(/^"(.*)"$/, '$1')).filter(Boolean);
|
||||
}
|
||||
return trimmed.split(',').map((t) => t.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizePersona(row: Record<string, unknown>): AgentPersona {
|
||||
return {
|
||||
id: String(row.id ?? ''),
|
||||
name: String(row.name ?? ''),
|
||||
description: String(row.description ?? ''),
|
||||
summary: String(row.summary ?? ''),
|
||||
detail: String(row.detail ?? ''),
|
||||
compiled_summary: String(row.compiled_summary ?? ''),
|
||||
compiled_detail: String(row.compiled_detail ?? ''),
|
||||
compiled_at: row.compiled_at ? String(row.compiled_at) : undefined,
|
||||
tags: normalizeTags(row.tags),
|
||||
created_at: String(row.created_at ?? ''),
|
||||
updated_at: String(row.updated_at ?? '')
|
||||
};
|
||||
}
|
||||
|
||||
function toForm(p: AgentPersona): PersonaForm {
|
||||
return { id: p.id, name: p.name, description: p.description, summary: p.summary, detail: p.detail, tags: p.tags.join(', ') };
|
||||
}
|
||||
|
||||
function normalizeForFormer(data: Record<string, unknown>): PersonaForm {
|
||||
return {
|
||||
id: data.id != null ? String(data.id) : undefined,
|
||||
name: String(data.name ?? ''),
|
||||
description: String(data.description ?? ''),
|
||||
summary: String(data.summary ?? ''),
|
||||
detail: String(data.detail ?? ''),
|
||||
tags: normalizeTags(data.tags).join(', ')
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeForm(data: PersonaForm): Record<string, unknown> {
|
||||
return {
|
||||
name: data.name.trim(),
|
||||
description: data.description.trim(),
|
||||
summary: data.summary.trim(),
|
||||
detail: data.detail.trim(),
|
||||
tags: data.tags.split(',').map((t) => t.trim()).filter(Boolean)
|
||||
};
|
||||
}
|
||||
|
||||
function onRowClick(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
if (!rowData) {
|
||||
selectedPersona = null;
|
||||
manifest = null;
|
||||
manifestError = '';
|
||||
return;
|
||||
}
|
||||
const persona = normalizePersona(rowData);
|
||||
selectedPersona = persona;
|
||||
void loadManifest(persona);
|
||||
}
|
||||
|
||||
function onRowContextMenu(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
contextRow = rowData ?? null;
|
||||
}
|
||||
|
||||
async function onMenuItemSelect(item: GridlerContextMenuItem) {
|
||||
if (item.id === 'add') {
|
||||
formValues = { name: '', description: '', summary: '', detail: '', tags: '' };
|
||||
formRequest = 'insert';
|
||||
formOpened = true;
|
||||
return;
|
||||
}
|
||||
if (!contextRow) return;
|
||||
const persona = normalizePersona(contextRow);
|
||||
if (item.id === 'edit_detail') {
|
||||
selectedPersona = persona;
|
||||
void loadManifest(persona);
|
||||
detailEditorValues = { id: persona.id, detail: persona.detail };
|
||||
detailEditorOpened = true;
|
||||
return;
|
||||
}
|
||||
formValues = toForm(persona);
|
||||
formRequest = item.id === 'delete' ? 'delete' : 'update';
|
||||
formOpened = true;
|
||||
}
|
||||
|
||||
function onRowDblClick(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
if (!rowData) return;
|
||||
contextRow = rowData;
|
||||
void onMenuItemSelect({ id: 'edit', label: 'Edit' });
|
||||
}
|
||||
|
||||
function onGridEvent(type: string, _i?: unknown, _c?: unknown, _co?: unknown, detail?: Record<string, unknown>) {
|
||||
if (type !== 'page_loaded' && type !== 'load') return;
|
||||
if (typeof detail?.total === 'number') gridTotal = detail.total;
|
||||
}
|
||||
|
||||
async function handleSaved() {
|
||||
formOpened = false;
|
||||
if (contextRow?.[PRIMARY_KEY]) {
|
||||
const data = await apiCall('read', 'update', undefined, String(contextRow[PRIMARY_KEY])) as Record<string, unknown>;
|
||||
const persona = normalizePersona(data);
|
||||
selectedPersona = persona;
|
||||
await loadManifest(persona);
|
||||
}
|
||||
refreshKey += 1;
|
||||
}
|
||||
|
||||
async function handleDetailSaved() {
|
||||
detailEditorOpened = false;
|
||||
if (detailEditorValues.id) {
|
||||
const data = await apiCall('read', 'update', undefined, detailEditorValues.id) as Record<string, unknown>;
|
||||
const persona = normalizePersona(data);
|
||||
selectedPersona = persona;
|
||||
await loadManifest(persona);
|
||||
}
|
||||
refreshKey += 1;
|
||||
}
|
||||
|
||||
async function loadManifest(persona: AgentPersona) {
|
||||
manifestLoading = true;
|
||||
manifestError = '';
|
||||
|
||||
try {
|
||||
const personaID = Number.parseInt(persona.id, 10);
|
||||
if (Number.isNaN(personaID)) throw new Error('Invalid persona id.');
|
||||
|
||||
const [partLinks, traitLinks, skillLinks, guardrailLinks, personaArcRows, allParts, allTraits, allSkills, allGuardrails, allArcs, allStages] = await Promise.all([
|
||||
rsReadMany('agent_persona_parts', { filters: [{ column: 'persona_id', operator: 'eq', value: personaID }] }),
|
||||
rsReadMany('agent_persona_traits', { filters: [{ column: 'persona_id', operator: 'eq', value: personaID }] }),
|
||||
rsReadMany('agent_persona_skills', { filters: [{ column: 'persona_id', operator: 'eq', value: personaID }] }),
|
||||
rsReadMany('agent_persona_guardrails', { filters: [{ column: 'persona_id', operator: 'eq', value: personaID }] }),
|
||||
rsReadMany('persona_arc', { filters: [{ column: 'persona_id', operator: 'eq', value: personaID }] }),
|
||||
rsReadMany('agent_parts', { limit: 500 }),
|
||||
rsReadMany('agent_traits', { limit: 500 }),
|
||||
rsReadMany('agent_skills', { limit: 500 }),
|
||||
rsReadMany('agent_guardrails', { limit: 500 }),
|
||||
rsReadMany('character_arcs', { limit: 500 }),
|
||||
rsReadMany('arc_stages', { limit: 1000 })
|
||||
]);
|
||||
|
||||
const partByID = new Map(allParts.map((row) => [Number(row.id), row]));
|
||||
const traitByID = new Map(allTraits.map((row) => [Number(row.id), row]));
|
||||
const skillByID = new Map(allSkills.map((row) => [Number(row.id), row]));
|
||||
const guardrailByID = new Map(allGuardrails.map((row) => [Number(row.id), row]));
|
||||
const arcByID = new Map(allArcs.map((row) => [Number(row.id), row]));
|
||||
const stageByID = new Map(allStages.map((row) => [Number(row.id), row]));
|
||||
|
||||
const parts = partLinks
|
||||
.map((link) => {
|
||||
const part = partByID.get(Number(link.part_id));
|
||||
if (!part) return null;
|
||||
return {
|
||||
name: String(part.name ?? ''),
|
||||
part_type: String(part.part_type ?? ''),
|
||||
description: String(part.description ?? ''),
|
||||
source: 'persona',
|
||||
part_order: Number(link.part_order ?? 0),
|
||||
priority: Number(link.priority ?? 0)
|
||||
} satisfies ManifestPart;
|
||||
})
|
||||
.filter(isDefined)
|
||||
.sort((a, b) => a.part_order - b.part_order || b.priority - a.priority || a.name.localeCompare(b.name));
|
||||
|
||||
const traits = traitLinks
|
||||
.map((link) => {
|
||||
const trait = traitByID.get(Number(link.trait_id));
|
||||
if (!trait) return null;
|
||||
return {
|
||||
name: String(trait.name ?? ''),
|
||||
trait_type: String(trait.trait_type ?? ''),
|
||||
description: String(trait.description ?? '')
|
||||
} satisfies ManifestTrait;
|
||||
})
|
||||
.filter(isDefined)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const skills = skillLinks
|
||||
.map((link) => {
|
||||
const skill = skillByID.get(Number(link.skill_id));
|
||||
if (!skill) return null;
|
||||
return {
|
||||
id: String(skill.id ?? ''),
|
||||
name: String(skill.name ?? ''),
|
||||
description: String(skill.description ?? '')
|
||||
} satisfies ManifestSkill;
|
||||
})
|
||||
.filter(isDefined)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const guardrails = guardrailLinks
|
||||
.map((link) => {
|
||||
const guardrail = guardrailByID.get(Number(link.guardrail_id));
|
||||
if (!guardrail) return null;
|
||||
return {
|
||||
id: String(guardrail.id ?? ''),
|
||||
name: String(guardrail.name ?? ''),
|
||||
description: String(guardrail.description ?? ''),
|
||||
severity: String(guardrail.severity ?? 'medium')
|
||||
} satisfies ManifestGuardrail;
|
||||
})
|
||||
.filter(isDefined)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const arcRow = personaArcRows[0];
|
||||
const arc = arcRow
|
||||
? (() => {
|
||||
const arcEntity = arcByID.get(Number(arcRow.arc_id));
|
||||
const stageEntity = stageByID.get(Number(arcRow.current_stage_id));
|
||||
return {
|
||||
arc_name: String(arcEntity?.name ?? ''),
|
||||
stage_name: String(stageEntity?.name ?? ''),
|
||||
stage_order: Number(stageEntity?.stage_order ?? 0),
|
||||
description: String(stageEntity?.description ?? ''),
|
||||
condition: String(stageEntity?.condition ?? '')
|
||||
} satisfies PersonaArcState;
|
||||
})()
|
||||
: null;
|
||||
|
||||
manifest = { parts, traits, skills, guardrails, arc };
|
||||
} catch (err) {
|
||||
manifest = null;
|
||||
manifestError = err instanceof Error ? err.message : 'Failed to load persona composition.';
|
||||
} finally {
|
||||
manifestLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(v?: string) {
|
||||
return v ? new Date(v).toLocaleString() : '—';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4 w-full">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-white">Personas</h2>
|
||||
<p class="mt-1 text-sm text-slate-400">
|
||||
{#if gridTotal === null}Server-backed grid{:else}{gridTotal} persona{gridTotal !== 1 ? 's' : ''}{/if}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="rounded-xl border border-cyan-300/30 bg-cyan-400/10 px-4 py-2 text-sm font-medium text-cyan-100 transition hover:bg-cyan-400/20"
|
||||
onclick={() => { formValues = { name: '', description: '', summary: '', detail: '', tags: '' }; formRequest = 'insert'; formOpened = true; }}
|
||||
>New Persona</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
|
||||
{#key refreshKey}
|
||||
<ErrorBoundary namespace="PersonasGridler">
|
||||
<GridlerFull
|
||||
{columns}
|
||||
theme={adminGridTheme}
|
||||
rowMarkers="number"
|
||||
height={400}
|
||||
width="100%"
|
||||
pageSize={40}
|
||||
dataSource="resolvespec"
|
||||
dataSourceOptions={dataSourceOptions}
|
||||
serverSideSearch={true}
|
||||
searchColumns={['name', 'description', 'summary']}
|
||||
{menuItems}
|
||||
{onGridEvent}
|
||||
{onRowClick}
|
||||
{onRowDblClick}
|
||||
{onRowContextMenu}
|
||||
{onMenuItemSelect}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<aside class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold text-white">Persona Inspector</h3>
|
||||
{#if selectedPersona}
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="text-xs text-cyan-300 hover:text-cyan-200"
|
||||
onclick={() => { if (!selectedPersona) return; detailEditorValues = { id: selectedPersona.id, detail: selectedPersona.detail }; detailEditorOpened = true; }}
|
||||
>Edit Detail</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !selectedPersona}
|
||||
<p class="mt-3 text-sm text-slate-500">Select a persona row to inspect.</p>
|
||||
{:else}
|
||||
<div class="mt-3 space-y-3 text-sm text-slate-300">
|
||||
<p class="text-base font-semibold text-slate-100">{selectedPersona.name}</p>
|
||||
{#if selectedPersona.description}
|
||||
<p><strong class="text-slate-100">Description:</strong> {selectedPersona.description}</p>
|
||||
{/if}
|
||||
<p><strong class="text-slate-100">Created:</strong> {formatDate(selectedPersona.created_at)}</p>
|
||||
<p><strong class="text-slate-100">Updated:</strong> {formatDate(selectedPersona.updated_at)}</p>
|
||||
{#if selectedPersona.compiled_at}
|
||||
<p><strong class="text-slate-100">Compiled:</strong> {formatDate(selectedPersona.compiled_at)}</p>
|
||||
{/if}
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Tags</p>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
{#if selectedPersona.tags.length}
|
||||
{#each selectedPersona.tags as tag}
|
||||
<span class="rounded-md bg-white/10 px-2 py-0.5 text-xs text-slate-300">{tag}</span>
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="text-slate-500">No tags</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Summary</p>
|
||||
<pre class="mt-2 overflow-x-auto rounded-xl bg-slate-950/60 p-3 text-xs text-slate-300 whitespace-pre-wrap">{selectedPersona.summary || '—'}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Detail</p>
|
||||
<pre class="mt-2 max-h-56 overflow-auto rounded-xl bg-slate-950/60 p-3 text-xs text-slate-300 whitespace-pre-wrap">{selectedPersona.detail || '—'}</pre>
|
||||
</div>
|
||||
{#if selectedPersona.compiled_summary}
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Compiled Summary</p>
|
||||
<pre class="mt-2 overflow-x-auto rounded-xl bg-slate-950/60 p-3 text-xs text-slate-300 whitespace-pre-wrap">{selectedPersona.compiled_summary}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
{#if selectedPersona.compiled_detail}
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Compiled Detail</p>
|
||||
<pre class="mt-2 max-h-64 overflow-auto rounded-xl bg-slate-950/60 p-3 text-xs text-slate-300 whitespace-pre-wrap">{selectedPersona.compiled_detail}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="rounded-2xl border border-white/10 bg-slate-950/40 p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Composition</p>
|
||||
{#if manifestLoading}
|
||||
<span class="text-xs text-slate-500">Loading…</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if manifestError}
|
||||
<p class="mt-3 text-sm text-rose-300">{manifestError}</p>
|
||||
{:else if manifest}
|
||||
<div class="mt-3 space-y-4">
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Arc</p>
|
||||
{#if manifest.arc}
|
||||
<div class="mt-2 rounded-xl bg-white/5 p-3 text-sm text-slate-300">
|
||||
<p class="font-medium text-slate-100">{manifest.arc.arc_name} · Stage {manifest.arc.stage_order}: {manifest.arc.stage_name}</p>
|
||||
{#if manifest.arc.description}
|
||||
<p class="mt-1">{manifest.arc.description}</p>
|
||||
{/if}
|
||||
{#if manifest.arc.condition}
|
||||
<p class="mt-2 text-xs text-slate-400">Condition: {manifest.arc.condition}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mt-2 text-sm text-slate-500">No arc assigned.</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Parts</p>
|
||||
{#if manifest.parts.length}
|
||||
<div class="mt-2 space-y-2">
|
||||
{#each manifest.parts as part}
|
||||
<div class="rounded-xl bg-white/5 p-3 text-sm text-slate-300">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium text-slate-100">{part.name}</span>
|
||||
<span class="rounded-md bg-cyan-400/10 px-2 py-0.5 text-[11px] text-cyan-300">{part.part_type}</span>
|
||||
<span class="text-[11px] text-slate-500">order {part.part_order ?? 0} · priority {part.priority ?? 0}</span>
|
||||
</div>
|
||||
{#if part.description}
|
||||
<p class="mt-1 text-xs text-slate-400">{part.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mt-2 text-sm text-slate-500">No parts linked.</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Traits</p>
|
||||
{#if manifest.traits.length}
|
||||
<div class="mt-2 space-y-2">
|
||||
{#each manifest.traits as trait}
|
||||
<div class="rounded-xl bg-white/5 p-3 text-sm text-slate-300">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium text-slate-100">{trait.name}</span>
|
||||
<span class="rounded-md bg-white/10 px-2 py-0.5 text-[11px] text-slate-300">{trait.trait_type}</span>
|
||||
</div>
|
||||
{#if trait.description}
|
||||
<p class="mt-1 text-xs text-slate-400">{trait.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mt-2 text-sm text-slate-500">No traits linked.</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Skills</p>
|
||||
{#if manifest.skills.length}
|
||||
<div class="mt-2 space-y-2">
|
||||
{#each manifest.skills as skill}
|
||||
<div class="rounded-xl bg-white/5 p-3 text-sm text-slate-300">
|
||||
<p class="font-medium text-slate-100">{skill.name}</p>
|
||||
{#if skill.description}
|
||||
<p class="mt-1 text-xs text-slate-400">{skill.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mt-2 text-sm text-slate-500">No skills linked.</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Guardrails</p>
|
||||
{#if manifest.guardrails.length}
|
||||
<div class="mt-2 space-y-2">
|
||||
{#each manifest.guardrails as guardrail}
|
||||
<div class="rounded-xl bg-white/5 p-3 text-sm text-slate-300">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium text-slate-100">{guardrail.name}</span>
|
||||
<span class="rounded-md bg-amber-400/10 px-2 py-0.5 text-[11px] text-amber-200">{guardrail.severity}</span>
|
||||
</div>
|
||||
{#if guardrail.description}
|
||||
<p class="mt-1 text-xs text-slate-400">{guardrail.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mt-2 text-sm text-slate-500">No guardrails linked.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mt-3 text-sm text-slate-500">Select a persona row to load linked parts, traits, skills, guardrails, and arc state.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorBoundary namespace="PersonaDetailEditor">
|
||||
<FormerShell
|
||||
bind:opened={detailEditorOpened}
|
||||
bind:values={detailEditorValues}
|
||||
request="update"
|
||||
title="Edit Persona Detail"
|
||||
uniqueKeyField={PRIMARY_KEY}
|
||||
width="min(96vw, 90rem)"
|
||||
onAPICall={apiCall}
|
||||
beforeSave={(data) => ({ detail: data.detail })}
|
||||
afterSave={handleDetailSaved}
|
||||
onClose={() => { detailEditorOpened = false; }}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<ContentEditorField
|
||||
filename="persona-detail.md"
|
||||
value={state.values?.detail ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, detail: v })}
|
||||
/>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
|
||||
<ErrorBoundary namespace="PersonasFormer">
|
||||
<FormerShell
|
||||
bind:opened={formOpened}
|
||||
bind:values={formValues}
|
||||
bind:request={formRequest}
|
||||
title={formRequest === 'insert' ? 'New Persona' : formRequest === 'update' ? 'Edit Persona' : 'Delete Persona'}
|
||||
uniqueKeyField={PRIMARY_KEY}
|
||||
onAPICall={apiCall}
|
||||
afterGet={async (data) => normalizeForFormer(data as Record<string, unknown>)}
|
||||
beforeSave={normalizeForm}
|
||||
afterSave={handleSaved}
|
||||
onClose={() => { formOpened = false; }}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<TextInputCtrl
|
||||
label="Name"
|
||||
name="name"
|
||||
required
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.name ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, name: v })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Description"
|
||||
name="description"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.description ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, description: v })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Tags"
|
||||
name="tags"
|
||||
placeholder="comma-separated"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.tags ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, tags: v })}
|
||||
/>
|
||||
<ContentEditorField
|
||||
filename="persona-summary.md"
|
||||
value={state.values?.summary ?? ''}
|
||||
disabled={state.request === 'delete'}
|
||||
onchange={(v) => state.setState('values', { ...state.values, summary: v })}
|
||||
/>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
338
ui/src/components/personas/TraitsTab.svelte
Normal file
338
ui/src/components/personas/TraitsTab.svelte
Normal file
@@ -0,0 +1,338 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ErrorBoundary,
|
||||
FormerResolveSpecAPI,
|
||||
GridlerFull,
|
||||
NativeSelectCtrl,
|
||||
TextInputCtrl,
|
||||
type GridlerColumn,
|
||||
type GridlerContextMenuItem
|
||||
} from '@warkypublic/svelix';
|
||||
import { adminGridTheme } from '../../gridTheme';
|
||||
import { GlobalStateStore } from '../../shellState';
|
||||
import type { AgentTrait } from '../../types';
|
||||
import FormerShell from '../shared/FormerShell.svelte';
|
||||
import ContentEditorField from '../shared/ContentEditorField.svelte';
|
||||
|
||||
type TraitForm = {
|
||||
id?: string;
|
||||
name: string;
|
||||
trait_type: string;
|
||||
description: string;
|
||||
instruction: string;
|
||||
tags: string;
|
||||
};
|
||||
|
||||
const TRAIT_TYPES = ['personality', 'cognitive', 'emotional', 'social', 'behavioral'];
|
||||
|
||||
const PRIMARY_KEY = 'id';
|
||||
const authToken = GlobalStateStore.getState().session.authToken ?? '';
|
||||
|
||||
let selectedTrait = $state<AgentTrait | null>(null);
|
||||
let gridTotal = $state<number | null>(null);
|
||||
let formOpened = $state(false);
|
||||
let formRequest = $state<'insert' | 'update' | 'delete'>('insert');
|
||||
let formValues = $state<TraitForm>({ name: '', trait_type: 'personality', description: '', instruction: '', tags: '' });
|
||||
let instructionEditorOpened = $state(false);
|
||||
let instructionEditorValues = $state<{ id?: string; instruction: string }>({ instruction: '' });
|
||||
let contextRow = $state<Record<string, unknown> | null>(null);
|
||||
let refreshKey = $state(0);
|
||||
|
||||
const apiCall = $derived(FormerResolveSpecAPI({
|
||||
authToken,
|
||||
url: '/api/rs/public/agent_traits'
|
||||
}));
|
||||
|
||||
const dataSourceOptions = {
|
||||
url: '/api/rs',
|
||||
authToken,
|
||||
schema: 'public',
|
||||
entity: 'agent_traits',
|
||||
uniqueID: PRIMARY_KEY,
|
||||
hotfields: [PRIMARY_KEY],
|
||||
sort: [{ column: 'trait_type', direction: 'asc' }, { column: 'name', direction: 'asc' }]
|
||||
} as unknown as { url: string; authToken?: string; schema: string; entity: string; uniqueID: string; hotfields: string[] };
|
||||
|
||||
const columns: GridlerColumn[] = [
|
||||
{ id: 'name', title: 'Name', dataKey: 'name', width: 220 },
|
||||
{ id: 'trait_type', title: 'Type', dataKey: 'trait_type', width: 130 },
|
||||
{ id: 'description', title: 'Description', dataKey: 'description', width: 300 },
|
||||
{ id: 'tags', title: 'Tags', dataKey: 'tags', width: 180 },
|
||||
{ id: 'updated_at', title: 'Updated', dataKey: 'updated_at', width: 160, format: 'datetime' }
|
||||
];
|
||||
|
||||
const menuItems: GridlerContextMenuItem[] = [
|
||||
{ id: 'add', label: 'Add' },
|
||||
{ id: 'edit', label: 'Edit' },
|
||||
{ id: 'edit_instruction', label: 'Edit Instruction' },
|
||||
{ id: 'delete', label: 'Delete' }
|
||||
];
|
||||
|
||||
function normalizeTags(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map((t) => String(t).trim()).filter(Boolean);
|
||||
if (typeof value !== 'string' || !value.trim()) return [];
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
||||
return trimmed.slice(1, -1).split(',').map((t) => t.trim().replace(/^"(.*)"$/, '$1')).filter(Boolean);
|
||||
}
|
||||
return trimmed.split(',').map((t) => t.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeTrait(row: Record<string, unknown>): AgentTrait {
|
||||
return {
|
||||
id: String(row.id ?? ''),
|
||||
name: String(row.name ?? ''),
|
||||
trait_type: String(row.trait_type ?? ''),
|
||||
description: String(row.description ?? ''),
|
||||
instruction: String(row.instruction ?? ''),
|
||||
tags: normalizeTags(row.tags),
|
||||
created_at: String(row.created_at ?? ''),
|
||||
updated_at: String(row.updated_at ?? '')
|
||||
};
|
||||
}
|
||||
|
||||
function toForm(t: AgentTrait): TraitForm {
|
||||
return { id: t.id, name: t.name, trait_type: t.trait_type, description: t.description, instruction: t.instruction, tags: t.tags.join(', ') };
|
||||
}
|
||||
|
||||
function normalizeForFormer(data: Record<string, unknown>): TraitForm {
|
||||
return {
|
||||
id: data.id != null ? String(data.id) : undefined,
|
||||
name: String(data.name ?? ''),
|
||||
trait_type: String(data.trait_type ?? 'personality'),
|
||||
description: String(data.description ?? ''),
|
||||
instruction: String(data.instruction ?? ''),
|
||||
tags: normalizeTags(data.tags).join(', ')
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeForm(data: TraitForm): Record<string, unknown> {
|
||||
return {
|
||||
name: data.name.trim(),
|
||||
trait_type: data.trait_type,
|
||||
description: data.description.trim(),
|
||||
instruction: data.instruction,
|
||||
tags: data.tags.split(',').map((t) => t.trim()).filter(Boolean)
|
||||
};
|
||||
}
|
||||
|
||||
function onRowClick(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
selectedTrait = rowData ? normalizeTrait(rowData) : null;
|
||||
}
|
||||
|
||||
function onRowContextMenu(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
contextRow = rowData ?? null;
|
||||
}
|
||||
|
||||
async function onMenuItemSelect(item: GridlerContextMenuItem) {
|
||||
if (item.id === 'add') {
|
||||
formValues = { name: '', trait_type: 'personality', description: '', instruction: '', tags: '' };
|
||||
formRequest = 'insert';
|
||||
formOpened = true;
|
||||
return;
|
||||
}
|
||||
if (!contextRow) return;
|
||||
const trait = normalizeTrait(contextRow);
|
||||
if (item.id === 'edit_instruction') {
|
||||
selectedTrait = trait;
|
||||
instructionEditorValues = { id: trait.id, instruction: trait.instruction };
|
||||
instructionEditorOpened = true;
|
||||
return;
|
||||
}
|
||||
formValues = toForm(trait);
|
||||
formRequest = item.id === 'delete' ? 'delete' : 'update';
|
||||
formOpened = true;
|
||||
}
|
||||
|
||||
function onRowDblClick(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
if (!rowData) return;
|
||||
contextRow = rowData;
|
||||
void onMenuItemSelect({ id: 'edit', label: 'Edit' });
|
||||
}
|
||||
|
||||
function onGridEvent(type: string, _i?: unknown, _c?: unknown, _co?: unknown, detail?: Record<string, unknown>) {
|
||||
if (type !== 'page_loaded' && type !== 'load') return;
|
||||
if (typeof detail?.total === 'number') gridTotal = detail.total;
|
||||
}
|
||||
|
||||
async function handleSaved() {
|
||||
formOpened = false;
|
||||
if (contextRow?.[PRIMARY_KEY]) {
|
||||
const data = await apiCall('read', 'update', undefined, String(contextRow[PRIMARY_KEY])) as Record<string, unknown>;
|
||||
selectedTrait = normalizeTrait(data);
|
||||
}
|
||||
refreshKey += 1;
|
||||
}
|
||||
|
||||
async function handleInstructionSaved() {
|
||||
instructionEditorOpened = false;
|
||||
if (instructionEditorValues.id) {
|
||||
const data = await apiCall('read', 'update', undefined, instructionEditorValues.id) as Record<string, unknown>;
|
||||
selectedTrait = normalizeTrait(data);
|
||||
}
|
||||
refreshKey += 1;
|
||||
}
|
||||
|
||||
function formatDate(v?: string) {
|
||||
return v ? new Date(v).toLocaleString() : '—';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4 w-full">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<p class="text-sm text-slate-400">
|
||||
{#if gridTotal === null}Server-backed grid{:else}{gridTotal} trait{gridTotal !== 1 ? 's' : ''}{/if}
|
||||
</p>
|
||||
<button
|
||||
class="rounded-xl border border-cyan-300/30 bg-cyan-400/10 px-4 py-2 text-sm font-medium text-cyan-100 transition hover:bg-cyan-400/20"
|
||||
onclick={() => { formValues = { name: '', trait_type: 'personality', description: '', instruction: '', tags: '' }; formRequest = 'insert'; formOpened = true; }}
|
||||
>New Trait</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
|
||||
{#key refreshKey}
|
||||
<ErrorBoundary namespace="TraitsGridler">
|
||||
<GridlerFull
|
||||
{columns}
|
||||
theme={adminGridTheme}
|
||||
rowMarkers="number"
|
||||
height={400}
|
||||
width="100%"
|
||||
pageSize={40}
|
||||
dataSource="resolvespec"
|
||||
dataSourceOptions={dataSourceOptions}
|
||||
serverSideSearch={true}
|
||||
searchColumns={['name', 'trait_type', 'description']}
|
||||
{menuItems}
|
||||
{onGridEvent}
|
||||
{onRowClick}
|
||||
{onRowDblClick}
|
||||
{onRowContextMenu}
|
||||
{onMenuItemSelect}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<aside class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold text-white">Trait Inspector</h3>
|
||||
{#if selectedTrait}
|
||||
<button
|
||||
class="text-xs text-cyan-300 hover:text-cyan-200"
|
||||
onclick={() => { if (!selectedTrait) return; instructionEditorValues = { id: selectedTrait.id, instruction: selectedTrait.instruction }; instructionEditorOpened = true; }}
|
||||
>Edit Instruction</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !selectedTrait}
|
||||
<p class="mt-3 text-sm text-slate-500">Select a trait row to inspect.</p>
|
||||
{:else}
|
||||
<div class="mt-3 space-y-3 text-sm text-slate-300">
|
||||
<div class="flex items-center gap-3">
|
||||
<p class="text-base font-semibold text-slate-100">{selectedTrait.name}</p>
|
||||
<span class="rounded-md bg-cyan-400/10 px-2 py-0.5 text-xs text-cyan-300">{selectedTrait.trait_type}</span>
|
||||
</div>
|
||||
{#if selectedTrait.description}
|
||||
<p><strong class="text-slate-100">Description:</strong> {selectedTrait.description}</p>
|
||||
{/if}
|
||||
<p><strong class="text-slate-100">Updated:</strong> {formatDate(selectedTrait.updated_at)}</p>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Tags</p>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
{#if selectedTrait.tags.length}
|
||||
{#each selectedTrait.tags as tag}
|
||||
<span class="rounded-md bg-white/10 px-2 py-0.5 text-xs text-slate-300">{tag}</span>
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="text-slate-500">No tags</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Instruction</p>
|
||||
<pre class="mt-2 overflow-x-auto rounded-xl bg-slate-950/60 p-3 text-xs text-slate-300 whitespace-pre-wrap">{selectedTrait.instruction || '—'}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorBoundary namespace="TraitInstructionEditor">
|
||||
<FormerShell
|
||||
bind:opened={instructionEditorOpened}
|
||||
bind:values={instructionEditorValues}
|
||||
request="update"
|
||||
title="Edit Trait Instruction"
|
||||
uniqueKeyField={PRIMARY_KEY}
|
||||
width="min(96vw, 90rem)"
|
||||
onAPICall={apiCall}
|
||||
beforeSave={(data) => ({ instruction: data.instruction })}
|
||||
afterSave={handleInstructionSaved}
|
||||
onClose={() => { instructionEditorOpened = false; }}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<ContentEditorField
|
||||
filename="trait-instruction.md"
|
||||
value={state.values?.instruction ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, instruction: v })}
|
||||
/>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
|
||||
<ErrorBoundary namespace="TraitsFormer">
|
||||
<FormerShell
|
||||
bind:opened={formOpened}
|
||||
bind:values={formValues}
|
||||
bind:request={formRequest}
|
||||
title={formRequest === 'insert' ? 'New Trait' : formRequest === 'update' ? 'Edit Trait' : 'Delete Trait'}
|
||||
uniqueKeyField={PRIMARY_KEY}
|
||||
onAPICall={apiCall}
|
||||
afterGet={async (data) => normalizeForFormer(data as Record<string, unknown>)}
|
||||
beforeSave={normalizeForm}
|
||||
afterSave={handleSaved}
|
||||
onClose={() => { formOpened = false; }}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<TextInputCtrl
|
||||
label="Name"
|
||||
name="name"
|
||||
required
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.name ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, name: v })}
|
||||
/>
|
||||
<NativeSelectCtrl
|
||||
label="Trait Type"
|
||||
name="trait_type"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.trait_type ?? 'personality'}
|
||||
options={TRAIT_TYPES}
|
||||
onchange={(v) => state.setState('values', { ...state.values, trait_type: v })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Description"
|
||||
name="description"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.description ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, description: v })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Tags"
|
||||
name="tags"
|
||||
placeholder="comma-separated"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.tags ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, tags: v })}
|
||||
/>
|
||||
<ContentEditorField
|
||||
filename="trait-instruction.md"
|
||||
value={state.values?.instruction ?? ''}
|
||||
disabled={state.request === 'delete'}
|
||||
onchange={(v) => state.setState('values', { ...state.values, instruction: v })}
|
||||
/>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
@@ -6,6 +6,7 @@
|
||||
import PlansPage from '../plans/PlansPage.svelte';
|
||||
import MaintenancePage from '../maintenance/MaintenancePage.svelte';
|
||||
import DashboardPage from '../dashboard/DashboardPage.svelte';
|
||||
import PersonasPage from '../personas/PersonasPage.svelte';
|
||||
import ProjectsPage from '../projects/ProjectsPage.svelte';
|
||||
import SkillsPage from '../skills/SkillsPage.svelte';
|
||||
import ThoughtsPage from '../thoughts/ThoughtsPage.svelte';
|
||||
@@ -48,6 +49,8 @@
|
||||
<SkillsPage />
|
||||
{:else if currentPage === 'guardrails'}
|
||||
<GuardrailsPage />
|
||||
{:else if currentPage === 'personas'}
|
||||
<PersonasPage />
|
||||
{:else if currentPage === 'files'}
|
||||
<FilesPage />
|
||||
{:else if currentPage === 'maintenance'}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
{ id: 'plans', label: 'Plans', description: 'Structured plans and workstreams.' },
|
||||
{ id: 'skills', label: 'Skills', description: 'Agent skill registry.' },
|
||||
{ id: 'guardrails', label: 'Guardrails', description: 'Agent guardrail registry.' },
|
||||
{ id: 'personas', label: 'Personas', description: 'Compose personas from parts and traits.' },
|
||||
{ id: 'files', label: 'Files', description: 'Stored file inventory.' },
|
||||
{ id: 'maintenance', label: 'Maintenance', description: 'Task state and upkeep actions.' }
|
||||
];
|
||||
|
||||
@@ -56,7 +56,7 @@ export type NavItem = {
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type ShellPage = 'dashboard' | 'projects' | 'thoughts' | 'learnings' | 'plans' | 'skills' | 'guardrails' | 'files' | 'maintenance';
|
||||
export type ShellPage = 'dashboard' | 'projects' | 'thoughts' | 'learnings' | 'plans' | 'skills' | 'guardrails' | 'personas' | 'files' | 'maintenance';
|
||||
|
||||
export type Project = {
|
||||
id: string;
|
||||
@@ -139,6 +139,52 @@ export type AgentGuardrail = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type AgentPersona = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
summary: string;
|
||||
detail: string;
|
||||
compiled_summary: string;
|
||||
compiled_detail: string;
|
||||
compiled_at?: string;
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type AgentPart = {
|
||||
id: string;
|
||||
name: string;
|
||||
part_type: string;
|
||||
description: string;
|
||||
summary: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type AgentTrait = {
|
||||
id: string;
|
||||
name: string;
|
||||
trait_type: string;
|
||||
description: string;
|
||||
instruction: string;
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type CharacterArc = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
summary: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type StoredFile = {
|
||||
id: string;
|
||||
thought_id?: string;
|
||||
|
||||
Reference in New Issue
Block a user