feat: add TraitsTab component for managing agent traits
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:
2026-05-05 14:51:58 +02:00
parent e285a03639
commit 9230f39cb6
48 changed files with 6979 additions and 2240 deletions

View 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>

View 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>

View 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>

View 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>

View File

@@ -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'}

View File

@@ -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.' }
];

View File

@@ -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;