feat(ui): add content editor components for skills and thoughts
CI / build-and-test (push) Failing after -31m24s
CI / build-and-test (push) Failing after -31m24s
* Implement ContentEditorField for inline editing of content * Create ContentEditorModal for editing content in a modal * Introduce FormerShell for managing forms related to skills and thoughts * Enhance SkillsPage and ThoughtsPage with new components for better content management
This commit is contained in:
@@ -1,91 +1,382 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '../../api';
|
||||
import {
|
||||
ErrorBoundary,
|
||||
FormerResolveSpecAPI,
|
||||
GridlerFull,
|
||||
TextInputCtrl,
|
||||
type GridlerColumn,
|
||||
type GridlerContextMenuItem
|
||||
} from '@warkypublic/svelix';
|
||||
import { adminGridTheme } from '../../gridTheme';
|
||||
import { GlobalStateStore } from '../../shellState';
|
||||
import type { AgentSkill } from '../../types';
|
||||
import FormerShell from '../shared/FormerShell.svelte';
|
||||
import ContentEditorField from '../shared/ContentEditorField.svelte';
|
||||
|
||||
let skills = $state<AgentSkill[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let busy = $state<string | null>(null);
|
||||
type SkillForm = {
|
||||
id?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
content: string;
|
||||
tags: string;
|
||||
};
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
skills = await api.skills.list();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load skills';
|
||||
} finally {
|
||||
loading = false;
|
||||
const SKILL_PRIMARY_KEY = 'id';
|
||||
|
||||
let selectedSkill = $state<AgentSkill | null>(null);
|
||||
let gridTotal = $state<number | null>(null);
|
||||
let formOpened = $state(false);
|
||||
let formRequest = $state<'insert' | 'update' | 'delete'>('insert');
|
||||
let formValues = $state<SkillForm>({
|
||||
name: '',
|
||||
description: '',
|
||||
content: '',
|
||||
tags: ''
|
||||
});
|
||||
let editorOpened = $state(false);
|
||||
let editorValues = $state<{ id?: string; content: string }>({ content: '' });
|
||||
let contextRow = $state<Record<string, unknown> | null>(null);
|
||||
let refreshKey = $state(0);
|
||||
const authToken = GlobalStateStore.getState().session.authToken ?? '';
|
||||
const skillOnAPICall = $derived(FormerResolveSpecAPI({
|
||||
authToken,
|
||||
url: '/api/rs/public/agent_skills'
|
||||
}));
|
||||
|
||||
const skillsDataSourceOptions = {
|
||||
url: '/api/rs',
|
||||
authToken: GlobalStateStore.getState().session.authToken,
|
||||
schema: 'public',
|
||||
entity: 'agent_skills',
|
||||
uniqueID: SKILL_PRIMARY_KEY,
|
||||
hotfields: [SKILL_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: 240 },
|
||||
{ id: 'description', title: 'Description', dataKey: 'description', width: 300 },
|
||||
{ id: 'tags', title: 'Tags', dataKey: 'tags', width: 220 },
|
||||
{ id: 'created_at', title: 'Created', dataKey: 'created_at', width: 180, format: 'datetime' },
|
||||
{ id: 'updated_at', title: 'Updated', dataKey: 'updated_at', width: 180, 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((tag) => String(tag).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((tag) => tag.trim().replace(/^"(.*)"$/, '$1'))
|
||||
.filter(Boolean);
|
||||
}
|
||||
return trimmed.split(',').map((tag) => tag.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
async function remove(id: string, name: string) {
|
||||
if (!confirm(`Delete skill "${name}"?`)) return;
|
||||
busy = id;
|
||||
try {
|
||||
await api.skills.delete(id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Delete failed';
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
function normalizeSkill(rowData: Record<string, unknown>): AgentSkill {
|
||||
return {
|
||||
id: String(rowData.id ?? ''),
|
||||
name: String(rowData.name ?? ''),
|
||||
description: String(rowData.description ?? ''),
|
||||
content: String(rowData.content ?? ''),
|
||||
tags: normalizeTags(rowData.tags),
|
||||
created_at: String(rowData.created_at ?? ''),
|
||||
updated_at: String(rowData.updated_at ?? '')
|
||||
};
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
function toSkillForm(skill: AgentSkill): SkillForm {
|
||||
return {
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
content: skill.content,
|
||||
tags: skill.tags.join(', ')
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSkillRecordForFormer(data: Record<string, unknown>): SkillForm {
|
||||
return {
|
||||
id: data.id != null ? String(data.id) : undefined,
|
||||
name: typeof data.name === 'string' ? data.name : '',
|
||||
description: typeof data.description === 'string' ? data.description : '',
|
||||
content: typeof data.content === 'string' ? data.content : '',
|
||||
tags: normalizeTags(data.tags).join(', ')
|
||||
};
|
||||
}
|
||||
|
||||
function onRowClick(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
selectedSkill = rowData ? normalizeSkill(rowData) : null;
|
||||
}
|
||||
|
||||
async function loadSkillFromRow(rowData: Record<string, unknown>): Promise<AgentSkill> {
|
||||
const id = String(rowData[SKILL_PRIMARY_KEY] ?? '');
|
||||
const data = await skillOnAPICall('read', 'update', undefined, id) as Record<string, unknown>;
|
||||
return normalizeSkill(data);
|
||||
}
|
||||
|
||||
function onRowContextMenu(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
contextRow = rowData ?? null;
|
||||
}
|
||||
|
||||
async function onMenuItemSelect(item: GridlerContextMenuItem) {
|
||||
if (item.id === 'add') {
|
||||
formValues = { name: '', description: '', content: '', tags: '' };
|
||||
formRequest = 'insert';
|
||||
formOpened = true;
|
||||
return;
|
||||
}
|
||||
if (!contextRow) return;
|
||||
if (item.id === 'edit_content') {
|
||||
const skill = normalizeSkill(contextRow);
|
||||
selectedSkill = skill;
|
||||
editorValues = { id: skill.id, content: skill.content };
|
||||
editorOpened = true;
|
||||
return;
|
||||
}
|
||||
const skill = normalizeSkill(contextRow);
|
||||
formValues = toSkillForm(skill);
|
||||
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,
|
||||
_item?: unknown,
|
||||
_column?: unknown,
|
||||
_coords?: unknown,
|
||||
detail?: Record<string, unknown>
|
||||
) {
|
||||
if (type !== 'page_loaded' && type !== 'load') return;
|
||||
const total = detail?.total;
|
||||
if (typeof total === 'number') gridTotal = total;
|
||||
}
|
||||
|
||||
function normalizeSkillForm(data: SkillForm): Record<string, unknown> {
|
||||
return {
|
||||
name: data.name.trim(),
|
||||
description: data.description.trim(),
|
||||
content: data.content,
|
||||
tags: data.tags.split(',').map((tag) => tag.trim()).filter(Boolean)
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSkillSaved() {
|
||||
formOpened = false;
|
||||
if (contextRow?.[SKILL_PRIMARY_KEY]) {
|
||||
const data = await skillOnAPICall('read', 'update', undefined, String(contextRow[SKILL_PRIMARY_KEY])) as Record<string, unknown>;
|
||||
selectedSkill = normalizeSkill(data);
|
||||
}
|
||||
refreshKey += 1;
|
||||
}
|
||||
|
||||
async function handleEditorSaved() {
|
||||
editorOpened = false;
|
||||
if (editorValues.id) {
|
||||
const data = await skillOnAPICall('read', 'update', undefined, String(editorValues.id)) as Record<string, unknown>;
|
||||
const refreshed = normalizeSkill(data);
|
||||
selectedSkill = refreshed;
|
||||
editorValues = { id: refreshed.id, content: refreshed.content };
|
||||
}
|
||||
refreshKey += 1;
|
||||
}
|
||||
|
||||
function formatDate(value?: string): string {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-end justify-between">
|
||||
<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">Skills</h2>
|
||||
<p class="mt-1 text-sm text-slate-400">{skills.length} skill{skills.length !== 1 ? 's' : ''}</p>
|
||||
<p class="mt-1 text-sm text-slate-400">
|
||||
{#if gridTotal === null}
|
||||
Server-backed grid
|
||||
{:else}
|
||||
{gridTotal} skill{gridTotal !== 1 ? 's' : ''}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<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: '', content: '', tags: '' };
|
||||
formRequest = 'insert';
|
||||
formOpened = true;
|
||||
}}
|
||||
>New Skill</button>
|
||||
</div>
|
||||
<button
|
||||
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
|
||||
onclick={load}
|
||||
>Refresh</button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-4 text-sm text-rose-100">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-400">Loading…</div>
|
||||
{:else if skills.length === 0}
|
||||
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-500">No skills registered.</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each skills as skill}
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold text-white">{skill.name}</p>
|
||||
{#if skill.description}
|
||||
<p class="mt-1 text-sm text-slate-400">{skill.description}</p>
|
||||
{/if}
|
||||
{#if skill.tags?.length}
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
{#each skill.tags as tag}
|
||||
<span class="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-400">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="shrink-0 text-xs text-rose-400 hover:text-rose-300 disabled:opacity-40"
|
||||
onclick={() => remove(skill.id, skill.name)}
|
||||
disabled={busy === skill.id}
|
||||
>Delete</button>
|
||||
</div>
|
||||
<details class="mt-3">
|
||||
<summary class="cursor-pointer text-xs text-slate-500 hover:text-slate-300">View content</summary>
|
||||
<pre class="mt-2 overflow-x-auto rounded-xl bg-slate-950/60 p-3 text-xs text-slate-300 whitespace-pre-wrap">{skill.content}</pre>
|
||||
</details>
|
||||
</div>
|
||||
{/each}
|
||||
<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="SkillsGridlerFull">
|
||||
<GridlerFull
|
||||
{columns}
|
||||
theme={adminGridTheme}
|
||||
rowMarkers="number"
|
||||
height={420}
|
||||
width="100%"
|
||||
pageSize={40}
|
||||
dataSource="resolvespec"
|
||||
dataSourceOptions={skillsDataSourceOptions}
|
||||
serverSideSearch={true}
|
||||
searchColumns={['name', 'description', 'content', 'tags']}
|
||||
{menuItems}
|
||||
{onGridEvent}
|
||||
{onRowClick}
|
||||
{onRowDblClick}
|
||||
{onRowContextMenu}
|
||||
{onMenuItemSelect}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<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">Skill Inspector</h3>
|
||||
{#if selectedSkill}
|
||||
<button
|
||||
class="text-xs text-cyan-300 hover:text-cyan-200"
|
||||
onclick={() => {
|
||||
if (!selectedSkill) return;
|
||||
editorValues = { id: selectedSkill.id, content: selectedSkill.content };
|
||||
editorOpened = true;
|
||||
}}
|
||||
>Edit Content</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !selectedSkill}
|
||||
<p class="mt-3 text-sm text-slate-500">
|
||||
Select a skill row to inspect details.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="mt-3 space-y-3 text-sm text-slate-300">
|
||||
<p class="text-base font-semibold text-slate-100">{selectedSkill.name}</p>
|
||||
<p><strong class="text-slate-100">Description:</strong> {selectedSkill.description || '—'}</p>
|
||||
<p><strong class="text-slate-100">Created:</strong> {formatDate(selectedSkill.created_at)}</p>
|
||||
<p><strong class="text-slate-100">Updated:</strong> {formatDate(selectedSkill.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 selectedSkill.tags.length}
|
||||
{#each selectedSkill.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">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">{selectedSkill.content}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorBoundary namespace="SkillsEditorFormer">
|
||||
<FormerShell
|
||||
bind:opened={editorOpened}
|
||||
bind:values={editorValues}
|
||||
request="update"
|
||||
title="Edit Skill Content"
|
||||
uniqueKeyField={SKILL_PRIMARY_KEY}
|
||||
width="min(96vw, 90rem)"
|
||||
onAPICall={skillOnAPICall}
|
||||
beforeSave={(data) => ({ content: data.content })}
|
||||
afterSave={handleEditorSaved}
|
||||
onClose={() => {
|
||||
editorOpened = false;
|
||||
}}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<ContentEditorField
|
||||
filename="skill.md"
|
||||
value={state.values?.content ?? ''}
|
||||
disabled={state.request === 'delete'}
|
||||
onchange={(v) => state.setState('values', { ...state.values, content: v })}
|
||||
/>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
|
||||
<ErrorBoundary namespace="SkillsFormer">
|
||||
<FormerShell
|
||||
bind:opened={formOpened}
|
||||
bind:values={formValues}
|
||||
bind:request={formRequest}
|
||||
title={formRequest === 'insert' ? 'New Skill' : formRequest === 'update' ? 'Edit Skill' : 'Delete Skill'}
|
||||
uniqueKeyField={SKILL_PRIMARY_KEY}
|
||||
onAPICall={skillOnAPICall}
|
||||
afterGet={async (data) => normalizeSkillRecordForFormer(data as Record<string, unknown>)}
|
||||
beforeSave={normalizeSkillForm}
|
||||
afterSave={handleSkillSaved}
|
||||
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="skill.md"
|
||||
value={state.values?.content ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, content: v })}
|
||||
/>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
|
||||
Reference in New Issue
Block a user