refactor(store): replace project and skill models with generated models
Some checks failed
CI / build-and-test (push) Failing after -31m25s
Some checks failed
CI / build-and-test (push) Failing after -31m25s
* Update project creation and retrieval to use generated models * Modify skill addition and listing to utilize generated models * Refactor thought handling to incorporate generated models * Adjust tool annotations to align with new model structure * Update API calls in the UI to use new ResolveSpec-based endpoints * Enhance stats retrieval logic to aggregate thought metadata
This commit is contained in:
247
ui/src/api.ts
247
ui/src/api.ts
@@ -5,6 +5,49 @@ function authHeaders(): HeadersInit {
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
type ResolveSpecResponse<T> = {
|
||||
success: boolean;
|
||||
data: T;
|
||||
metadata?: unknown;
|
||||
error?: { code: string; message: string; detail?: string };
|
||||
};
|
||||
|
||||
type ResolveSpecFilter = {
|
||||
column: string;
|
||||
operator: string;
|
||||
value?: unknown;
|
||||
};
|
||||
|
||||
function normalizeTags(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((tag) => String(tag).trim()).filter(Boolean);
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
// Handle Postgres text[] wire shape: "{tag1,tag2}".
|
||||
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
||||
return trimmed
|
||||
.slice(1, -1)
|
||||
.split(',')
|
||||
.map((tag) => tag.trim().replace(/^"(.*)"$/, '$1'))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (trimmed.includes(',')) {
|
||||
return trimmed
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return [trimmed];
|
||||
}
|
||||
|
||||
async function get<T>(path: string): Promise<T> {
|
||||
const res = await fetch(path, { headers: authHeaders() });
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
@@ -27,55 +70,193 @@ async function del(path: string): Promise<void> {
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
async function rsCall<T>(
|
||||
path: string,
|
||||
operation: 'read' | 'create' | 'update' | 'delete',
|
||||
payload?: { data?: unknown; options?: unknown }
|
||||
): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({
|
||||
operation,
|
||||
...(payload?.data !== undefined ? { data: payload.data } : {}),
|
||||
...(payload?.options !== undefined ? { options: payload.options } : {})
|
||||
})
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
const body = (await res.json()) as ResolveSpecResponse<T>;
|
||||
if (!body.success) {
|
||||
throw new Error(body.error?.message ?? 'ResolveSpec request failed');
|
||||
}
|
||||
return body.data;
|
||||
}
|
||||
|
||||
function rsReadMany<T>(
|
||||
entity: string,
|
||||
options?: { filters?: ResolveSpecFilter[]; limit?: number; sort?: { column: string; direction: 'asc' | 'desc' }[] }
|
||||
): Promise<T[]> {
|
||||
return rsCall<T[]>(`/api/rs/public/${entity}`, 'read', {
|
||||
options: {
|
||||
...(options?.filters?.length ? { filters: options.filters } : {}),
|
||||
...(options?.sort?.length ? { sort: options.sort } : {}),
|
||||
...(options?.limit ? { limit: options.limit } : {})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const api = {
|
||||
projects: {
|
||||
list: () => get<import('./types').ProjectSummary[]>('/api/admin/projects'),
|
||||
list: async () => {
|
||||
type ProjectRow = {
|
||||
guid: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
last_active_at: string;
|
||||
thought_count?: number;
|
||||
};
|
||||
|
||||
const projects = await rsCall<ProjectRow[]>('/api/rs/public/projects', 'read', {
|
||||
options: {
|
||||
columns: ['guid', 'name', 'description', 'created_at', 'last_active_at'],
|
||||
computedColumns: [
|
||||
{
|
||||
name: 'thought_count',
|
||||
expression: 'COALESCE((SELECT COUNT(*) FROM public.thoughts t WHERE t.project_id = projects.guid), 0)'
|
||||
}
|
||||
],
|
||||
sort: [{ column: 'created_at', direction: 'desc' }],
|
||||
limit: 500
|
||||
}
|
||||
});
|
||||
|
||||
return projects.map((project) => ({
|
||||
id: project.guid,
|
||||
name: project.name,
|
||||
description: project.description ?? '',
|
||||
created_at: project.created_at,
|
||||
last_active_at: project.last_active_at,
|
||||
thought_count: project.thought_count ?? 0
|
||||
}));
|
||||
},
|
||||
create: (name: string, description: string) =>
|
||||
post<import('./types').Project>('/api/admin/projects', { name, description })
|
||||
rsCall<import('./types').Project>('/api/rs/public/projects', 'create', {
|
||||
data: { name, description }
|
||||
})
|
||||
},
|
||||
thoughts: {
|
||||
list: (params: { q?: string; project_id?: string; limit?: number; include_archived?: boolean }) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.q) qs.set('q', params.q);
|
||||
if (params.project_id) qs.set('project_id', params.project_id);
|
||||
if (params.limit) qs.set('limit', String(params.limit));
|
||||
if (params.include_archived) qs.set('include_archived', 'true');
|
||||
return get<(import('./types').Thought | import('./types').SearchResult)[]>(
|
||||
`/api/admin/thoughts${qs.size ? '?' + qs : ''}`
|
||||
);
|
||||
const filters: ResolveSpecFilter[] = [];
|
||||
if (params.q) {
|
||||
filters.push({ column: 'content', operator: 'ilike', value: `%${params.q}%` });
|
||||
}
|
||||
if (params.project_id) {
|
||||
filters.push({ column: 'project_id', operator: 'eq', value: params.project_id });
|
||||
}
|
||||
if (!params.include_archived) {
|
||||
filters.push({ column: 'archived_at', operator: 'empty' });
|
||||
}
|
||||
return rsReadMany<import('./types').Thought>('thoughts', {
|
||||
filters,
|
||||
limit: params.limit ?? 100,
|
||||
sort: [{ column: 'created_at', direction: 'desc' }]
|
||||
});
|
||||
},
|
||||
get: (id: string) => get<import('./types').Thought>(`/api/admin/thoughts/${id}`),
|
||||
delete: (id: string) => del(`/api/admin/thoughts/${id}`),
|
||||
archive: (id: string) => post<void>(`/api/admin/thoughts/${id}/archive`, {})
|
||||
get: (id: string) => rsCall<import('./types').Thought>(`/api/rs/public/thoughts/${id}`, 'read'),
|
||||
delete: (id: string) => rsCall<void>(`/api/rs/public/thoughts/${id}`, 'delete'),
|
||||
archive: (id: string) =>
|
||||
rsCall<void>(`/api/rs/public/thoughts/${id}`, 'update', {
|
||||
data: { archived_at: new Date().toISOString() }
|
||||
})
|
||||
},
|
||||
skills: {
|
||||
list: (tag?: string) => {
|
||||
const qs = tag ? `?tag=${encodeURIComponent(tag)}` : '';
|
||||
return get<import('./types').AgentSkill[]>(`/api/admin/skills${qs}`);
|
||||
list: async (tag?: string) => {
|
||||
const rows = await rsReadMany<Omit<import('./types').AgentSkill, 'tags'> & { tags?: unknown }>('agent_skills', {
|
||||
filters: tag ? [{ column: 'tags', operator: 'contains', value: tag }] : undefined,
|
||||
limit: 500,
|
||||
sort: [{ column: 'created_at', direction: 'desc' }]
|
||||
});
|
||||
return rows.map((row) => ({ ...row, tags: normalizeTags(row.tags) }));
|
||||
},
|
||||
delete: (id: string) => del(`/api/admin/skills/${id}`)
|
||||
delete: (id: string) => rsCall<void>(`/api/rs/public/agent_skills/${id}`, 'delete')
|
||||
},
|
||||
guardrails: {
|
||||
list: (params?: { tag?: string; severity?: string }) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.tag) qs.set('tag', params.tag);
|
||||
if (params?.severity) qs.set('severity', params.severity);
|
||||
return get<import('./types').AgentGuardrail[]>(
|
||||
`/api/admin/guardrails${qs.size ? '?' + qs : ''}`
|
||||
);
|
||||
list: async (params?: { tag?: string; severity?: string }) => {
|
||||
const filters: ResolveSpecFilter[] = [];
|
||||
if (params?.tag) filters.push({ column: 'tags', operator: 'contains', value: params.tag });
|
||||
if (params?.severity) filters.push({ column: 'severity', operator: 'eq', value: params.severity });
|
||||
|
||||
const rows = await rsReadMany<Omit<import('./types').AgentGuardrail, 'tags'> & { tags?: unknown }>('agent_guardrails', {
|
||||
filters,
|
||||
limit: 500,
|
||||
sort: [{ column: 'created_at', direction: 'desc' }]
|
||||
});
|
||||
return rows.map((row) => ({ ...row, tags: normalizeTags(row.tags) }));
|
||||
},
|
||||
delete: (id: string) => del(`/api/admin/guardrails/${id}`)
|
||||
delete: (id: string) => rsCall<void>(`/api/rs/public/agent_guardrails/${id}`, 'delete')
|
||||
},
|
||||
files: {
|
||||
list: (params?: { project_id?: string; thought_id?: string; kind?: string }) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.project_id) qs.set('project_id', params.project_id);
|
||||
if (params?.thought_id) qs.set('thought_id', params.thought_id);
|
||||
if (params?.kind) qs.set('kind', params.kind);
|
||||
return get<import('./types').StoredFile[]>(
|
||||
`/api/admin/files${qs.size ? '?' + qs : ''}`
|
||||
);
|
||||
const filters: ResolveSpecFilter[] = [];
|
||||
if (params?.project_id) filters.push({ column: 'project_id', operator: 'eq', value: params.project_id });
|
||||
if (params?.thought_id) filters.push({ column: 'thought_id', operator: 'eq', value: params.thought_id });
|
||||
if (params?.kind) filters.push({ column: 'kind', operator: 'eq', value: params.kind });
|
||||
return rsReadMany<import('./types').StoredFile>('stored_files', {
|
||||
filters,
|
||||
limit: 500,
|
||||
sort: [{ column: 'created_at', direction: 'desc' }]
|
||||
});
|
||||
}
|
||||
},
|
||||
stats: () => get<import('./types').ThoughtStats>('/api/admin/stats')
|
||||
stats: async () => {
|
||||
type StatsThoughtRow = {
|
||||
metadata?: {
|
||||
type?: string;
|
||||
topics?: string[];
|
||||
people?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
const thoughts = await rsReadMany<StatsThoughtRow>('thoughts', {
|
||||
limit: 5000,
|
||||
sort: [{ column: 'created_at', direction: 'desc' }]
|
||||
});
|
||||
|
||||
const typeCounts: Record<string, number> = {};
|
||||
const topicCounts: Record<string, number> = {};
|
||||
const peopleCounts: Record<string, number> = {};
|
||||
|
||||
for (const thought of thoughts) {
|
||||
const type = thought.metadata?.type?.trim() || 'unknown';
|
||||
typeCounts[type] = (typeCounts[type] ?? 0) + 1;
|
||||
|
||||
for (const topic of thought.metadata?.topics ?? []) {
|
||||
const key = topic.trim();
|
||||
if (!key) continue;
|
||||
topicCounts[key] = (topicCounts[key] ?? 0) + 1;
|
||||
}
|
||||
|
||||
for (const person of thought.metadata?.people ?? []) {
|
||||
const key = person.trim();
|
||||
if (!key) continue;
|
||||
peopleCounts[key] = (peopleCounts[key] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const topEntries = (map: Record<string, number>) =>
|
||||
Object.entries(map)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([key, count]) => ({ key, count }));
|
||||
|
||||
const stats: import('./types').ThoughtStats = {
|
||||
total_count: thoughts.length,
|
||||
type_counts: typeCounts,
|
||||
top_topics: topEntries(topicCounts),
|
||||
top_people: topEntries(peopleCounts)
|
||||
};
|
||||
|
||||
return stats;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user