feat: web prompt editor + agent personalities #58
986
pnpm-lock.yaml
generated
986
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
4
src/web/.gitignore
vendored
Normal file
4
src/web/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
dist
|
||||
node_modules
|
||||
.vite
|
||||
*.log
|
||||
21
src/web/index.html
Normal file
21
src/web/index.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>mcpctl — prompt editor</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
html, body, #root { height: 100%; margin: 0; padding: 0; }
|
||||
body {
|
||||
background: #0d1117;
|
||||
color: #c9d1d9;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Inter", sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
28
src/web/package.json
Normal file
28
src/web/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@mcpctl/web",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.7.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"jsdom": "^28.0.0",
|
||||
"vite": "^7.2.0"
|
||||
}
|
||||
}
|
||||
42
src/web/src/App.tsx
Normal file
42
src/web/src/App.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { getToken } from './api';
|
||||
import { Layout } from './components/Layout';
|
||||
import { Login } from './components/Login';
|
||||
import { ProjectsPage } from './pages/Projects';
|
||||
import { ProjectPromptsPage } from './pages/ProjectPrompts';
|
||||
import { AgentsPage } from './pages/Agents';
|
||||
import { AgentDetailPage } from './pages/AgentDetail';
|
||||
import { PersonalityDetailPage } from './pages/PersonalityDetail';
|
||||
|
||||
export function App(): React.JSX.Element {
|
||||
const [tokenPresent, setTokenPresent] = useState(getToken() !== null);
|
||||
|
||||
// Listen for storage changes from other tabs.
|
||||
useEffect(() => {
|
||||
const onStorage = (): void => setTokenPresent(getToken() !== null);
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => window.removeEventListener('storage', onStorage);
|
||||
}, []);
|
||||
|
||||
if (!tokenPresent) {
|
||||
return <Login onLogin={() => setTokenPresent(true)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<BrowserRouter basename="/ui">
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Navigate to="/projects" replace />} />
|
||||
<Route path="/projects" element={<ProjectsPage />} />
|
||||
<Route path="/projects/:name/prompts" element={<ProjectPromptsPage />} />
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/agents/:name" element={<AgentDetailPage />} />
|
||||
<Route path="/personalities/:id" element={<PersonalityDetailPage />} />
|
||||
<Route path="*" element={<Navigate to="/projects" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
103
src/web/src/api.ts
Normal file
103
src/web/src/api.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Thin fetch wrapper over mcpd's HTTP API.
|
||||
*
|
||||
* Reads the bearer token from localStorage on every request — the user can
|
||||
* paste either a session token (from `mcpctl auth login`) or a PAT
|
||||
* (`mcpctl_pat_*`). Both flow through the same `Authorization: Bearer …`
|
||||
* header that mcpd already accepts (see `src/mcpd/src/middleware/auth.ts`).
|
||||
*
|
||||
* The wrapper deliberately stays minimal — no caching, no retry policy, no
|
||||
* cancellation tokens. Add those when a real call site needs them.
|
||||
*/
|
||||
|
||||
const TOKEN_KEY = 'mcpctl.token';
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function clearToken(): void {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export interface ApiError extends Error {
|
||||
status: number;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (token !== null) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const init: RequestInit = { method, headers };
|
||||
if (body !== undefined) init.body = JSON.stringify(body);
|
||||
const res = await fetch(path, init);
|
||||
|
||||
if (!res.ok) {
|
||||
let parsed: unknown = null;
|
||||
try { parsed = await res.json(); } catch { /* ignore */ }
|
||||
const err = new Error(`HTTP ${String(res.status)} ${res.statusText}`) as ApiError;
|
||||
err.status = res.status;
|
||||
err.body = parsed;
|
||||
throw err;
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string): Promise<T> => request<T>('GET', path),
|
||||
post: <T>(path: string, body: unknown): Promise<T> => request<T>('POST', path, body),
|
||||
put: <T>(path: string, body: unknown): Promise<T> => request<T>('PUT', path, body),
|
||||
delete: <T = void>(path: string): Promise<T> => request<T>('DELETE', path),
|
||||
};
|
||||
|
||||
// ── Domain types (subset of what the UI needs from mcpd) ──
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
llm: { id: string; name: string };
|
||||
project: { id: string; name: string } | null;
|
||||
defaultPersonality: { id: string; name: string } | null;
|
||||
}
|
||||
|
||||
export interface Prompt {
|
||||
id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
projectId: string | null;
|
||||
agentId: string | null;
|
||||
priority: number;
|
||||
linkTarget: string | null;
|
||||
}
|
||||
|
||||
export interface Personality {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
priority: number;
|
||||
promptCount: number;
|
||||
}
|
||||
|
||||
export interface PersonalityPrompt {
|
||||
promptId: string;
|
||||
promptName: string;
|
||||
promptContent: string;
|
||||
priority: number;
|
||||
}
|
||||
80
src/web/src/components/Layout.tsx
Normal file
80
src/web/src/components/Layout.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import * as React from 'react';
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { clearToken } from '../api';
|
||||
|
||||
/**
|
||||
* Top-of-page nav + outlet. Terminal-style dark theme so the UI feels
|
||||
* adjacent to the CLI rather than a separate product.
|
||||
*/
|
||||
export function Layout(): React.JSX.Element {
|
||||
return (
|
||||
<div style={styles.shell}>
|
||||
<header style={styles.header}>
|
||||
<div style={styles.brand}>mcpctl <span style={styles.dim}>· prompt editor</span></div>
|
||||
<nav style={styles.nav}>
|
||||
<NavLink to="/projects" style={navStyle}>Projects</NavLink>
|
||||
<NavLink to="/agents" style={navStyle}>Agents</NavLink>
|
||||
<button
|
||||
style={styles.logout}
|
||||
onClick={() => { clearToken(); window.location.assign('/ui/'); }}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
<main style={styles.main}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function navStyle({ isActive }: { isActive: boolean }): React.CSSProperties {
|
||||
return {
|
||||
color: isActive ? '#58a6ff' : '#c9d1d9',
|
||||
textDecoration: 'none',
|
||||
padding: '6px 12px',
|
||||
borderBottom: isActive ? '2px solid #58a6ff' : '2px solid transparent',
|
||||
};
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
shell: {
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 24px',
|
||||
background: '#161b22',
|
||||
borderBottom: '1px solid #30363d',
|
||||
},
|
||||
brand: {
|
||||
fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace',
|
||||
fontWeight: 700,
|
||||
fontSize: 16,
|
||||
},
|
||||
dim: { color: '#7d8590', fontWeight: 400 },
|
||||
nav: {
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
logout: {
|
||||
background: 'transparent',
|
||||
color: '#c9d1d9',
|
||||
border: '1px solid #30363d',
|
||||
padding: '4px 12px',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
marginLeft: 12,
|
||||
},
|
||||
main: {
|
||||
flex: 1,
|
||||
padding: 24,
|
||||
overflowY: 'auto',
|
||||
},
|
||||
};
|
||||
120
src/web/src/components/Login.tsx
Normal file
120
src/web/src/components/Login.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { setToken } from '../api';
|
||||
|
||||
/**
|
||||
* Login screen — paste a session token (`mcpctl auth login` writes one to
|
||||
* ~/.mcpctl/credentials.json) or a PAT (`mcpctl_pat_*`). The token lives in
|
||||
* localStorage; logout wipes it. We don't validate the token shape here —
|
||||
* the first API call will 401 if it's wrong, and the user re-enters.
|
||||
*/
|
||||
export function Login({ onLogin }: { onLogin: () => void }): React.JSX.Element {
|
||||
const [value, setValue] = useState('');
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
|
||||
function submit(e: React.FormEvent): void {
|
||||
e.preventDefault();
|
||||
if (value.trim() === '') return;
|
||||
setToken(value.trim());
|
||||
onLogin();
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.shell}>
|
||||
<form style={styles.card} onSubmit={submit}>
|
||||
<h1 style={styles.title}>mcpctl <span style={styles.dim}>prompt editor</span></h1>
|
||||
<p style={styles.hint}>Paste a session token or PAT.</p>
|
||||
<input
|
||||
type="password"
|
||||
autoFocus
|
||||
placeholder="mcpctl_pat_… or session token"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
style={styles.input}
|
||||
/>
|
||||
<button type="submit" style={styles.button}>Continue</button>
|
||||
<button
|
||||
type="button"
|
||||
style={styles.linkButton}
|
||||
onClick={() => setShowHelp((s) => !s)}
|
||||
>
|
||||
{showHelp ? 'Hide help' : 'Where do I get a token?'}
|
||||
</button>
|
||||
{showHelp && (
|
||||
<pre style={styles.help}>
|
||||
{`# Use your interactive session token (writes to ~/.mcpctl/credentials.json)
|
||||
mcpctl auth login
|
||||
cat ~/.mcpctl/credentials.json # field: \`token\`
|
||||
|
||||
# Or mint a PAT for a specific project (longer-lived)
|
||||
mcpctl create mcptoken my-editor --project my-project
|
||||
`}
|
||||
</pre>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
shell: {
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
},
|
||||
card: {
|
||||
width: '100%',
|
||||
maxWidth: 420,
|
||||
background: '#161b22',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 6,
|
||||
padding: 32,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
},
|
||||
title: {
|
||||
margin: 0,
|
||||
fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace',
|
||||
fontWeight: 700,
|
||||
fontSize: 20,
|
||||
},
|
||||
dim: { color: '#7d8590', fontWeight: 400 },
|
||||
hint: { margin: 0, color: '#7d8590' },
|
||||
input: {
|
||||
background: '#0d1117',
|
||||
color: '#c9d1d9',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 4,
|
||||
padding: '8px 12px',
|
||||
fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace',
|
||||
},
|
||||
button: {
|
||||
background: '#238636',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600,
|
||||
},
|
||||
linkButton: {
|
||||
background: 'transparent',
|
||||
color: '#58a6ff',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
padding: 0,
|
||||
},
|
||||
help: {
|
||||
background: '#0d1117',
|
||||
color: '#c9d1d9',
|
||||
padding: 12,
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
overflowX: 'auto',
|
||||
margin: 0,
|
||||
},
|
||||
};
|
||||
155
src/web/src/components/PromptEditor.tsx
Normal file
155
src/web/src/components/PromptEditor.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import Editor from '@monaco-editor/react';
|
||||
|
||||
/**
|
||||
* Inline prompt editor: name + priority + Monaco for content.
|
||||
* Used by ProjectPrompts, AgentDetail (direct prompts tab), and
|
||||
* PersonalityDetail (binding-attached prompts).
|
||||
*
|
||||
* The component is intentionally dumb — it owns no I/O. The parent does the
|
||||
* POST/PUT and decides whether to show this in "create" or "edit" mode.
|
||||
*/
|
||||
export interface PromptDraft {
|
||||
name: string;
|
||||
priority: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface PromptEditorProps {
|
||||
initial?: Partial<PromptDraft>;
|
||||
/** Lock the name field — used when editing existing prompts (name is the unique key). */
|
||||
nameLocked?: boolean;
|
||||
submitLabel: string;
|
||||
onSubmit: (draft: PromptDraft) => Promise<void> | void;
|
||||
onCancel?: () => void;
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
export function PromptEditor(props: PromptEditorProps): React.JSX.Element {
|
||||
const [name, setName] = useState(props.initial?.name ?? '');
|
||||
const [priority, setPriority] = useState(props.initial?.priority ?? 5);
|
||||
const [content, setContent] = useState(props.initial?.content ?? '');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (!/^[a-z0-9-]+$/.test(name)) {
|
||||
setError('Name must be lowercase alphanumeric with hyphens (e.g., "tone-rules").');
|
||||
return;
|
||||
}
|
||||
if (content.trim().length === 0) {
|
||||
setError('Content is required.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await props.onSubmit({ name, priority, content });
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} style={styles.form}>
|
||||
<div style={styles.row}>
|
||||
<label style={styles.label}>
|
||||
Name
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={props.nameLocked === true}
|
||||
placeholder="lowercase-with-hyphens"
|
||||
style={styles.input}
|
||||
/>
|
||||
</label>
|
||||
<label style={styles.label}>
|
||||
Priority (1-10)
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(Number(e.target.value))}
|
||||
style={{ ...styles.input, width: 80 }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div style={styles.editorShell}>
|
||||
<Editor
|
||||
height="320px"
|
||||
theme="vs-dark"
|
||||
defaultLanguage="markdown"
|
||||
value={content}
|
||||
onChange={(v) => setContent(v ?? '')}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
wordWrap: 'on',
|
||||
fontSize: 13,
|
||||
automaticLayout: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{error !== null && <div style={styles.error}>{error}</div>}
|
||||
<div style={styles.actions}>
|
||||
<button type="submit" disabled={props.busy === true} style={styles.primary}>
|
||||
{props.busy === true ? 'Saving…' : props.submitLabel}
|
||||
</button>
|
||||
{props.onCancel !== undefined && (
|
||||
<button type="button" onClick={props.onCancel} style={styles.secondary}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
form: { display: 'flex', flexDirection: 'column', gap: 12 },
|
||||
row: { display: 'flex', gap: 16, alignItems: 'flex-end' },
|
||||
label: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
fontSize: 12,
|
||||
color: '#7d8590',
|
||||
flex: 1,
|
||||
},
|
||||
input: {
|
||||
background: '#0d1117',
|
||||
color: '#c9d1d9',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 4,
|
||||
padding: '6px 10px',
|
||||
fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace',
|
||||
},
|
||||
editorShell: { border: '1px solid #30363d', borderRadius: 4, overflow: 'hidden' },
|
||||
error: {
|
||||
background: '#2d1416',
|
||||
color: '#ff7b72',
|
||||
border: '1px solid #f85149',
|
||||
padding: '8px 12px',
|
||||
borderRadius: 4,
|
||||
fontSize: 13,
|
||||
},
|
||||
actions: { display: 'flex', gap: 8 },
|
||||
primary: {
|
||||
background: '#238636',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600,
|
||||
},
|
||||
secondary: {
|
||||
background: 'transparent',
|
||||
color: '#c9d1d9',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 4,
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
};
|
||||
35
src/web/src/hooks/useFetch.ts
Normal file
35
src/web/src/hooks/useFetch.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Minimal SWR-style hook: call `fn`, expose `{ data, error, loading, refetch }`.
|
||||
* No global cache — each consumer fetches its own copy. Add caching when you
|
||||
* see the same query firing 5+ times across mounted components.
|
||||
*/
|
||||
export interface UseFetchResult<T> {
|
||||
data: T | null;
|
||||
error: Error | null;
|
||||
loading: boolean;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export function useFetch<T>(fn: () => Promise<T>, deps: unknown[] = []): UseFetchResult<T> {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [bump, setBump] = useState(0);
|
||||
|
||||
const refetch = useCallback(() => setBump((b) => b + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fn()
|
||||
.then((d) => { if (!cancelled) { setData(d); setLoading(false); } })
|
||||
.catch((e: Error) => { if (!cancelled) { setError(e); setLoading(false); } });
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bump, ...deps]);
|
||||
|
||||
return { data, error, loading, refetch };
|
||||
}
|
||||
11
src/web/src/main.tsx
Normal file
11
src/web/src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
|
||||
const root = document.getElementById('root');
|
||||
if (root === null) throw new Error('#root not found');
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
317
src/web/src/pages/AgentDetail.tsx
Normal file
317
src/web/src/pages/AgentDetail.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { api, type Agent, type Prompt, type Personality } from '../api';
|
||||
import { useFetch } from '../hooks/useFetch';
|
||||
import { PromptEditor, type PromptDraft } from '../components/PromptEditor';
|
||||
|
||||
/**
|
||||
* Agent detail page. Two tabs:
|
||||
* - Direct prompts (Prompt.agentId === agent.id) — always-on overlay.
|
||||
* - Personalities — list, create, click-through to bind prompts.
|
||||
*
|
||||
* Why both on one page: an agent's "what it does" lives in this triple
|
||||
* (systemPrompt + direct prompts + personalities). Splitting them across
|
||||
* routes hides the relationship that the chat engine cares about.
|
||||
*/
|
||||
type Tab = 'direct' | 'personalities';
|
||||
|
||||
export function AgentDetailPage(): React.JSX.Element {
|
||||
const { name } = useParams<{ name: string }>();
|
||||
const agentName = name ?? '';
|
||||
const { data: agent, error: agentError, loading: agentLoading } = useFetch(
|
||||
() => api.get<Agent>(`/api/v1/agents/${encodeURIComponent(agentName)}`),
|
||||
[agentName],
|
||||
);
|
||||
const [tab, setTab] = useState<Tab>('direct');
|
||||
|
||||
if (agentLoading) return <div>Loading agent…</div>;
|
||||
if (agentError !== null) return <div style={{ color: '#ff7b72' }}>Error: {agentError.message}</div>;
|
||||
if (agent === null) return <div>Not found.</div>;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<p style={{ marginTop: 0 }}>
|
||||
<Link to="/agents" style={{ color: '#58a6ff' }}>← Agents</Link>
|
||||
</p>
|
||||
<h2 style={{ marginTop: 0 }}>
|
||||
{agent.name}
|
||||
<span style={{ color: '#7d8590', fontSize: 14, marginLeft: 12 }}>
|
||||
{agent.llm.name}{agent.project !== null && ` · ${agent.project.name}`}
|
||||
</span>
|
||||
</h2>
|
||||
{agent.systemPrompt !== '' && (
|
||||
<details style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: 6, padding: 12, margin: '12px 0' }}>
|
||||
<summary style={{ cursor: 'pointer', color: '#7d8590' }}>System prompt</summary>
|
||||
<pre style={{ background: '#0d1117', padding: 12, borderRadius: 4, marginTop: 8, whiteSpace: 'pre-wrap' }}>{agent.systemPrompt}</pre>
|
||||
</details>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 4, borderBottom: '1px solid #30363d', marginBottom: 16 }}>
|
||||
<TabButton active={tab === 'direct'} onClick={() => setTab('direct')}>Direct prompts</TabButton>
|
||||
<TabButton active={tab === 'personalities'} onClick={() => setTab('personalities')}>Personalities</TabButton>
|
||||
</div>
|
||||
{tab === 'direct' && <DirectPromptsTab agentName={agentName} />}
|
||||
{tab === 'personalities' && <PersonalitiesTab agentName={agentName} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }): React.JSX.Element {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
color: active ? '#58a6ff' : '#c9d1d9',
|
||||
border: 'none',
|
||||
borderBottom: active ? '2px solid #58a6ff' : '2px solid transparent',
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
marginBottom: -1,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Direct prompts tab ──
|
||||
|
||||
function DirectPromptsTab({ agentName }: { agentName: string }): React.JSX.Element {
|
||||
const { data, error, loading, refetch } = useFetch(
|
||||
() => api.get<Prompt[]>(`/api/v1/agents/${encodeURIComponent(agentName)}/prompts`),
|
||||
[agentName],
|
||||
);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
if (loading) return <div>Loading…</div>;
|
||||
if (error !== null) return <div style={{ color: '#ff7b72' }}>Error: {error.message}</div>;
|
||||
const prompts = data ?? [];
|
||||
|
||||
async function handleCreate(draft: PromptDraft): Promise<void> {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.post('/api/v1/prompts', {
|
||||
name: draft.name,
|
||||
content: draft.content,
|
||||
priority: draft.priority,
|
||||
agent: agentName,
|
||||
});
|
||||
setCreating(false);
|
||||
refetch();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string, name: string): Promise<void> {
|
||||
if (!confirm(`Delete prompt '${name}'?`)) return;
|
||||
await api.delete(`/api/v1/prompts/${id}`);
|
||||
refetch();
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p style={{ color: '#7d8590', marginTop: 0 }}>
|
||||
Always-on prompts for this agent. Injected after the agent's system prompt and before project prompts.
|
||||
</p>
|
||||
{!creating && (
|
||||
<button onClick={() => setCreating(true)} style={primaryBtn}>+ New direct prompt</button>
|
||||
)}
|
||||
{creating && (
|
||||
<div style={card}>
|
||||
<h3>New direct prompt</h3>
|
||||
<PromptEditor
|
||||
submitLabel="Create"
|
||||
onSubmit={handleCreate}
|
||||
onCancel={() => setCreating(false)}
|
||||
busy={busy}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{prompts.length === 0 && !creating && <p style={{ color: '#7d8590' }}>No direct prompts yet.</p>}
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: '16px 0 0 0' }}>
|
||||
{prompts.sort((a, b) => b.priority - a.priority).map((p) => (
|
||||
<li key={p.id} style={card}>
|
||||
<div style={cardHeader}>
|
||||
<div>
|
||||
<strong style={{ fontFamily: 'ui-monospace, monospace' }}>{p.name}</strong>
|
||||
<span style={{ color: '#7d8590', marginLeft: 8, fontSize: 12 }}>priority {p.priority}</span>
|
||||
</div>
|
||||
<button onClick={() => handleDelete(p.id, p.name)} style={dangerBtn}>Delete</button>
|
||||
</div>
|
||||
<pre style={contentPre}>{p.content}</pre>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Personalities tab ──
|
||||
|
||||
function PersonalitiesTab({ agentName }: { agentName: string }): React.JSX.Element {
|
||||
const { data, error, loading, refetch } = useFetch(
|
||||
() => api.get<Personality[]>(`/api/v1/agents/${encodeURIComponent(agentName)}/personalities`),
|
||||
[agentName],
|
||||
);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
if (loading) return <div>Loading…</div>;
|
||||
if (error !== null) return <div style={{ color: '#ff7b72' }}>Error: {error.message}</div>;
|
||||
const personalities = data ?? [];
|
||||
|
||||
async function handleCreate(e: React.FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
if (!/^[a-z0-9-]+$/.test(name)) {
|
||||
alert('Name must be lowercase alphanumeric with hyphens.');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.post(`/api/v1/agents/${encodeURIComponent(agentName)}/personalities`, {
|
||||
name, description,
|
||||
});
|
||||
setName('');
|
||||
setDescription('');
|
||||
setCreating(false);
|
||||
refetch();
|
||||
} catch (err) {
|
||||
alert((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string, name: string): Promise<void> {
|
||||
if (!confirm(`Delete personality '${name}'?`)) return;
|
||||
await api.delete(`/api/v1/personalities/${id}`);
|
||||
refetch();
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p style={{ color: '#7d8590', marginTop: 0 }}>
|
||||
Named overlays of prompts. Pick at chat time with <code>--personality <name></code> or set
|
||||
a default on the agent.
|
||||
</p>
|
||||
{!creating && (
|
||||
<button onClick={() => setCreating(true)} style={primaryBtn}>+ New personality</button>
|
||||
)}
|
||||
{creating && (
|
||||
<form onSubmit={handleCreate} style={card}>
|
||||
<h3>New personality</h3>
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 12 }}>
|
||||
<label style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12, color: '#7d8590' }}>
|
||||
Name
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="grumpy"
|
||||
style={input}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ flex: 2, display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12, color: '#7d8590' }}>
|
||||
Description
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Be terse and slightly grumpy"
|
||||
style={input}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="submit" disabled={busy} style={primaryBtn}>{busy ? 'Saving…' : 'Create'}</button>
|
||||
<button type="button" onClick={() => setCreating(false)} style={secondaryBtn}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{personalities.length === 0 && !creating && <p style={{ color: '#7d8590' }}>No personalities yet.</p>}
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: '16px 0 0 0' }}>
|
||||
{personalities.map((p) => (
|
||||
<li key={p.id} style={card}>
|
||||
<div style={cardHeader}>
|
||||
<div>
|
||||
<Link
|
||||
to={`/personalities/${p.id}`}
|
||||
style={{ color: '#58a6ff', fontFamily: 'ui-monospace, monospace', fontWeight: 600, textDecoration: 'none' }}
|
||||
>
|
||||
{p.name}
|
||||
</Link>
|
||||
<span style={{ color: '#7d8590', marginLeft: 8, fontSize: 12 }}>
|
||||
{p.promptCount} prompt{p.promptCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={() => handleDelete(p.id, p.name)} style={dangerBtn}>Delete</button>
|
||||
</div>
|
||||
{p.description !== '' && <div style={{ color: '#c9d1d9' }}>{p.description}</div>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const card: React.CSSProperties = {
|
||||
background: '#161b22',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 6,
|
||||
padding: 16,
|
||||
margin: '12px 0',
|
||||
};
|
||||
const cardHeader: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
};
|
||||
const contentPre: React.CSSProperties = {
|
||||
background: '#0d1117',
|
||||
color: '#c9d1d9',
|
||||
padding: 12,
|
||||
borderRadius: 4,
|
||||
margin: 0,
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace',
|
||||
fontSize: 12,
|
||||
};
|
||||
const input: React.CSSProperties = {
|
||||
background: '#0d1117',
|
||||
color: '#c9d1d9',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 4,
|
||||
padding: '6px 10px',
|
||||
fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace',
|
||||
};
|
||||
const primaryBtn: React.CSSProperties = {
|
||||
background: '#238636',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600,
|
||||
};
|
||||
const secondaryBtn: React.CSSProperties = {
|
||||
background: 'transparent',
|
||||
color: '#c9d1d9',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 4,
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
const dangerBtn: React.CSSProperties = {
|
||||
background: 'transparent',
|
||||
color: '#ff7b72',
|
||||
border: '1px solid #f85149',
|
||||
borderRadius: 4,
|
||||
padding: '4px 12px',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
40
src/web/src/pages/Agents.tsx
Normal file
40
src/web/src/pages/Agents.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import * as React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api, type Agent } from '../api';
|
||||
import { useFetch } from '../hooks/useFetch';
|
||||
|
||||
export function AgentsPage(): React.JSX.Element {
|
||||
const { data, error, loading } = useFetch(() => api.get<Agent[]>('/api/v1/agents'));
|
||||
|
||||
if (loading) return <div>Loading agents…</div>;
|
||||
if (error !== null) return <div style={{ color: '#ff7b72' }}>Error: {error.message}</div>;
|
||||
const agents = data ?? [];
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2 style={{ marginTop: 0 }}>Agents</h2>
|
||||
<p style={{ color: '#7d8590' }}>Pick an agent to manage its direct prompts and personalities.</p>
|
||||
{agents.length === 0 && <p>No agents yet — create one with <code>mcpctl create agent</code>.</p>}
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||
{agents.map((a) => (
|
||||
<li key={a.id} style={{ padding: '12px 0', borderBottom: '1px solid #30363d' }}>
|
||||
<Link
|
||||
to={`/agents/${encodeURIComponent(a.name)}`}
|
||||
style={{ color: '#58a6ff', textDecoration: 'none', fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace', fontSize: 15 }}
|
||||
>
|
||||
{a.name}
|
||||
</Link>
|
||||
<div style={{ color: '#7d8590', fontSize: 12, marginTop: 4 }}>
|
||||
LLM: {a.llm.name}
|
||||
{a.project !== null && <> · Project: {a.project.name}</>}
|
||||
{a.defaultPersonality !== null && <> · Default personality: {a.defaultPersonality.name}</>}
|
||||
</div>
|
||||
{a.description !== '' && (
|
||||
<div style={{ color: '#c9d1d9', marginTop: 4 }}>{a.description}</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
224
src/web/src/pages/PersonalityDetail.tsx
Normal file
224
src/web/src/pages/PersonalityDetail.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { api, type Personality, type PersonalityPrompt, type Prompt } from '../api';
|
||||
import { useFetch } from '../hooks/useFetch';
|
||||
|
||||
/**
|
||||
* Personality detail: show metadata, list bound prompts (with priority
|
||||
* within this overlay), and offer an "attach prompt" picker that lets the
|
||||
* user select from in-scope candidates (agent-direct + same-project + global).
|
||||
*/
|
||||
export function PersonalityDetailPage(): React.JSX.Element {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const personalityId = id ?? '';
|
||||
|
||||
const personality = useFetch(
|
||||
() => api.get<Personality>(`/api/v1/personalities/${encodeURIComponent(personalityId)}`),
|
||||
[personalityId],
|
||||
);
|
||||
const bindings = useFetch(
|
||||
() => api.get<PersonalityPrompt[]>(`/api/v1/personalities/${encodeURIComponent(personalityId)}/prompts`),
|
||||
[personalityId],
|
||||
);
|
||||
|
||||
if (personality.loading || bindings.loading) return <div>Loading…</div>;
|
||||
if (personality.error !== null) return <div style={{ color: '#ff7b72' }}>Error: {personality.error.message}</div>;
|
||||
if (personality.data === null) return <div>Not found.</div>;
|
||||
|
||||
const p = personality.data;
|
||||
const bound = bindings.data ?? [];
|
||||
|
||||
return (
|
||||
<section>
|
||||
<p style={{ marginTop: 0 }}>
|
||||
<Link to={`/agents/${encodeURIComponent(p.agentName)}`} style={{ color: '#58a6ff' }}>
|
||||
← {p.agentName}
|
||||
</Link>
|
||||
</p>
|
||||
<h2 style={{ marginTop: 0 }}>
|
||||
{p.name}
|
||||
<span style={{ color: '#7d8590', fontSize: 14, marginLeft: 12 }}>personality on {p.agentName}</span>
|
||||
</h2>
|
||||
{p.description !== '' && <p style={{ color: '#c9d1d9' }}>{p.description}</p>}
|
||||
|
||||
<h3>Bound prompts ({bound.length})</h3>
|
||||
<p style={{ color: '#7d8590', marginTop: 0 }}>
|
||||
Activated when this personality is selected at chat time. Priority controls order within
|
||||
the overlay (higher first).
|
||||
</p>
|
||||
{bound.length === 0 && <p style={{ color: '#7d8590' }}>No bound prompts yet.</p>}
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||
{bound
|
||||
.slice()
|
||||
.sort((a, b) => b.priority - a.priority)
|
||||
.map((b) => (
|
||||
<li key={b.promptId} style={card}>
|
||||
<div style={cardHeader}>
|
||||
<div>
|
||||
<strong style={{ fontFamily: 'ui-monospace, monospace' }}>{b.promptName}</strong>
|
||||
<span style={{ color: '#7d8590', marginLeft: 8, fontSize: 12 }}>priority {b.priority}</span>
|
||||
</div>
|
||||
<button onClick={() => detach(personalityId, b.promptId, b.promptName, bindings.refetch)} style={dangerBtn}>
|
||||
Detach
|
||||
</button>
|
||||
</div>
|
||||
<pre style={contentPre}>{b.promptContent}</pre>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<AttachPromptPanel
|
||||
personality={p}
|
||||
boundPromptIds={bound.map((b) => b.promptId)}
|
||||
onAttached={bindings.refetch}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
async function detach(
|
||||
personalityId: string,
|
||||
promptId: string,
|
||||
promptName: string,
|
||||
refetch: () => void,
|
||||
): Promise<void> {
|
||||
if (!confirm(`Detach prompt '${promptName}' from this personality?`)) return;
|
||||
await api.delete(`/api/v1/personalities/${personalityId}/prompts/${promptId}`);
|
||||
refetch();
|
||||
}
|
||||
|
||||
// ── Attach picker ──
|
||||
|
||||
function AttachPromptPanel(props: {
|
||||
personality: Personality;
|
||||
boundPromptIds: string[];
|
||||
onAttached: () => void;
|
||||
}): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
const candidates = useFetch(
|
||||
async () => {
|
||||
// In-scope candidates: agent-direct + same-project + global.
|
||||
const direct = await api.get<Prompt[]>(
|
||||
`/api/v1/agents/${encodeURIComponent(props.personality.agentName)}/prompts`,
|
||||
);
|
||||
const agentRow = await api.get<{ project: { name: string } | null }>(
|
||||
`/api/v1/agents/${encodeURIComponent(props.personality.agentName)}`,
|
||||
);
|
||||
let projectAndGlobal: Prompt[] = [];
|
||||
if (agentRow.project !== null) {
|
||||
projectAndGlobal = await api.get<Prompt[]>(
|
||||
`/api/v1/prompts?project=${encodeURIComponent(agentRow.project.name)}`,
|
||||
);
|
||||
} else {
|
||||
projectAndGlobal = await api.get<Prompt[]>(`/api/v1/prompts?scope=global`);
|
||||
}
|
||||
// Dedupe; exclude ones already bound.
|
||||
const seen = new Set<string>(props.boundPromptIds);
|
||||
const merged: Prompt[] = [];
|
||||
for (const p of [...direct, ...projectAndGlobal]) {
|
||||
if (seen.has(p.id)) continue;
|
||||
seen.add(p.id);
|
||||
merged.push(p);
|
||||
}
|
||||
return merged;
|
||||
},
|
||||
[props.personality.id, props.boundPromptIds.join(',')],
|
||||
);
|
||||
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
async function attach(promptId: string): Promise<void> {
|
||||
setBusyId(promptId);
|
||||
try {
|
||||
await api.post(`/api/v1/personalities/${props.personality.id}/prompts`, { promptId });
|
||||
props.onAttached();
|
||||
} catch (err) {
|
||||
alert((err as Error).message);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return <button onClick={() => setOpen(true)} style={primaryBtn}>+ Attach prompt</button>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={card}>
|
||||
<h3 style={{ marginTop: 0 }}>Attach a prompt</h3>
|
||||
<p style={{ color: '#7d8590', marginTop: 0 }}>
|
||||
Eligible: prompts on this agent, prompts in the agent's project, or globals.
|
||||
</p>
|
||||
{candidates.loading && <div>Loading candidates…</div>}
|
||||
{candidates.error !== null && <div style={{ color: '#ff7b72' }}>Error: {candidates.error.message}</div>}
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||
{(candidates.data ?? []).map((p) => (
|
||||
<li key={p.id} style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0', borderBottom: '1px solid #30363d' }}>
|
||||
<div>
|
||||
<strong style={{ fontFamily: 'ui-monospace, monospace' }}>{p.name}</strong>
|
||||
<span style={{ color: '#7d8590', marginLeft: 8, fontSize: 12 }}>
|
||||
{p.agentId !== null ? 'agent-direct' : p.projectId !== null ? 'project' : 'global'}
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={() => attach(p.id)} disabled={busyId === p.id} style={secondaryBtn}>
|
||||
{busyId === p.id ? 'Attaching…' : 'Attach'}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{(candidates.data ?? []).length === 0 && !candidates.loading && (
|
||||
<li style={{ color: '#7d8590', padding: '8px 0' }}>No eligible prompts to attach.</li>
|
||||
)}
|
||||
</ul>
|
||||
<button onClick={() => setOpen(false)} style={secondaryBtn}>Close</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const card: React.CSSProperties = {
|
||||
background: '#161b22',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 6,
|
||||
padding: 16,
|
||||
margin: '12px 0',
|
||||
};
|
||||
const cardHeader: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
};
|
||||
const contentPre: React.CSSProperties = {
|
||||
background: '#0d1117',
|
||||
color: '#c9d1d9',
|
||||
padding: 12,
|
||||
borderRadius: 4,
|
||||
margin: 0,
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace',
|
||||
fontSize: 12,
|
||||
};
|
||||
const primaryBtn: React.CSSProperties = {
|
||||
background: '#238636',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600,
|
||||
};
|
||||
const secondaryBtn: React.CSSProperties = {
|
||||
background: 'transparent',
|
||||
color: '#c9d1d9',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 4,
|
||||
padding: '4px 12px',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
const dangerBtn: React.CSSProperties = {
|
||||
background: 'transparent',
|
||||
color: '#ff7b72',
|
||||
border: '1px solid #f85149',
|
||||
borderRadius: 4,
|
||||
padding: '4px 12px',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
177
src/web/src/pages/ProjectPrompts.tsx
Normal file
177
src/web/src/pages/ProjectPrompts.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { api, type Prompt } from '../api';
|
||||
import { useFetch } from '../hooks/useFetch';
|
||||
import { PromptEditor, type PromptDraft } from '../components/PromptEditor';
|
||||
|
||||
/**
|
||||
* Project prompts editor:
|
||||
* - GET /api/v1/prompts?project=<name> to list (project-scoped + globals)
|
||||
* - filter to project-only for this view
|
||||
* - inline create / edit / delete; Monaco for content
|
||||
*/
|
||||
export function ProjectPromptsPage(): React.JSX.Element {
|
||||
const { name } = useParams<{ name: string }>();
|
||||
const projectName = name ?? '';
|
||||
const { data, error, loading, refetch } = useFetch(
|
||||
() => api.get<Prompt[]>(`/api/v1/prompts?project=${encodeURIComponent(projectName)}`),
|
||||
[projectName],
|
||||
);
|
||||
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
if (loading) return <div>Loading prompts…</div>;
|
||||
if (error !== null) return <div style={{ color: '#ff7b72' }}>Error: {error.message}</div>;
|
||||
const all = data ?? [];
|
||||
// Filter to project-scoped only — the API includes globals as well.
|
||||
const prompts = all.filter((p) => p.projectId !== null);
|
||||
|
||||
async function handleCreate(draft: PromptDraft): Promise<void> {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.post('/api/v1/prompts', {
|
||||
name: draft.name,
|
||||
content: draft.content,
|
||||
priority: draft.priority,
|
||||
project: projectName,
|
||||
});
|
||||
setCreating(false);
|
||||
refetch();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdate(id: string, draft: PromptDraft): Promise<void> {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.put(`/api/v1/prompts/${id}`, {
|
||||
content: draft.content,
|
||||
priority: draft.priority,
|
||||
});
|
||||
setEditingId(null);
|
||||
refetch();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string, name: string): Promise<void> {
|
||||
if (!confirm(`Delete prompt '${name}'?`)) return;
|
||||
await api.delete(`/api/v1/prompts/${id}`);
|
||||
refetch();
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<p style={{ marginTop: 0 }}>
|
||||
<Link to="/projects" style={{ color: '#58a6ff' }}>← Projects</Link>
|
||||
</p>
|
||||
<h2 style={{ marginTop: 0 }}>{projectName}<span style={{ color: '#7d8590' }}> · prompts</span></h2>
|
||||
{!creating && (
|
||||
<button onClick={() => setCreating(true)} style={primaryBtn}>+ New prompt</button>
|
||||
)}
|
||||
{creating && (
|
||||
<div style={card}>
|
||||
<h3>New prompt</h3>
|
||||
<PromptEditor
|
||||
submitLabel="Create"
|
||||
onSubmit={handleCreate}
|
||||
onCancel={() => setCreating(false)}
|
||||
busy={busy}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{prompts.length === 0 && !creating && (
|
||||
<p style={{ color: '#7d8590' }}>No prompts in this project yet.</p>
|
||||
)}
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: '16px 0 0 0' }}>
|
||||
{prompts.sort((a, b) => b.priority - a.priority).map((p) => (
|
||||
<li key={p.id} style={card}>
|
||||
{editingId === p.id ? (
|
||||
<>
|
||||
<h3>Edit prompt: {p.name}</h3>
|
||||
<PromptEditor
|
||||
initial={{ name: p.name, priority: p.priority, content: p.content }}
|
||||
nameLocked
|
||||
submitLabel="Save"
|
||||
onSubmit={(d) => handleUpdate(p.id, d)}
|
||||
onCancel={() => setEditingId(null)}
|
||||
busy={busy}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={cardHeader}>
|
||||
<div>
|
||||
<strong style={{ fontFamily: 'ui-monospace, monospace' }}>{p.name}</strong>
|
||||
<span style={{ color: '#7d8590', marginLeft: 8, fontSize: 12 }}>
|
||||
priority {p.priority}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={() => setEditingId(p.id)} style={secondaryBtn}>Edit</button>
|
||||
<button onClick={() => handleDelete(p.id, p.name)} style={dangerBtn}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre style={contentPre}>{p.content}</pre>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const card: React.CSSProperties = {
|
||||
background: '#161b22',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 6,
|
||||
padding: 16,
|
||||
margin: '12px 0',
|
||||
};
|
||||
const cardHeader: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
};
|
||||
const contentPre: React.CSSProperties = {
|
||||
background: '#0d1117',
|
||||
color: '#c9d1d9',
|
||||
padding: 12,
|
||||
borderRadius: 4,
|
||||
margin: 0,
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace',
|
||||
fontSize: 12,
|
||||
};
|
||||
const primaryBtn: React.CSSProperties = {
|
||||
background: '#238636',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600,
|
||||
};
|
||||
const secondaryBtn: React.CSSProperties = {
|
||||
background: 'transparent',
|
||||
color: '#c9d1d9',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 4,
|
||||
padding: '4px 12px',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
const dangerBtn: React.CSSProperties = {
|
||||
background: 'transparent',
|
||||
color: '#ff7b72',
|
||||
border: '1px solid #f85149',
|
||||
borderRadius: 4,
|
||||
padding: '4px 12px',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
35
src/web/src/pages/Projects.tsx
Normal file
35
src/web/src/pages/Projects.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import * as React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api, type Project } from '../api';
|
||||
import { useFetch } from '../hooks/useFetch';
|
||||
|
||||
export function ProjectsPage(): React.JSX.Element {
|
||||
const { data, error, loading } = useFetch(() => api.get<Project[]>('/api/v1/projects'));
|
||||
|
||||
if (loading) return <div>Loading projects…</div>;
|
||||
if (error !== null) return <div style={{ color: '#ff7b72' }}>Error: {error.message}</div>;
|
||||
const projects = data ?? [];
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2 style={{ marginTop: 0 }}>Projects</h2>
|
||||
<p style={{ color: '#7d8590' }}>Pick a project to edit its prompts.</p>
|
||||
{projects.length === 0 && <p>No projects yet.</p>}
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||
{projects.map((p) => (
|
||||
<li key={p.id} style={{ padding: '8px 0', borderBottom: '1px solid #30363d' }}>
|
||||
<Link
|
||||
to={`/projects/${encodeURIComponent(p.name)}/prompts`}
|
||||
style={{ color: '#58a6ff', textDecoration: 'none', fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace' }}
|
||||
>
|
||||
{p.name}
|
||||
</Link>
|
||||
{p.description !== undefined && p.description !== '' && (
|
||||
<span style={{ color: '#7d8590', marginLeft: 12 }}>{p.description}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
62
src/web/tests/api.test.ts
Normal file
62
src/web/tests/api.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { api, setToken, clearToken, type ApiError } from '../src/api';
|
||||
|
||||
describe('api wrapper', () => {
|
||||
beforeEach(() => {
|
||||
clearToken();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('attaches the bearer token from localStorage', async () => {
|
||||
setToken('mcpctl_pat_xyz');
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200, headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await api.get('/api/v1/agents');
|
||||
|
||||
const calls = fetchMock.mock.calls as unknown as Array<[unknown, RequestInit]>;
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
const init = calls[0]![1];
|
||||
expect(init.headers).toMatchObject({
|
||||
'Authorization': 'Bearer mcpctl_pat_xyz',
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the bearer header when no token is set', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response('[]', {
|
||||
status: 200, headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await api.get('/api/v1/agents');
|
||||
|
||||
const calls = fetchMock.mock.calls as unknown as Array<[unknown, RequestInit]>;
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
const init = calls[0]![1];
|
||||
expect(init.headers).not.toHaveProperty('Authorization');
|
||||
});
|
||||
|
||||
it('throws an ApiError with status + parsed body on 4xx/5xx', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(
|
||||
JSON.stringify({ error: 'nope' }),
|
||||
{ status: 422, statusText: 'Unprocessable', headers: { 'content-type': 'application/json' } },
|
||||
));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(api.get('/api/v1/oops')).rejects.toMatchObject({
|
||||
status: 422,
|
||||
body: { error: 'nope' },
|
||||
} satisfies Partial<ApiError>);
|
||||
});
|
||||
|
||||
it('handles 204 No Content responses without parsing JSON', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await api.delete('/api/v1/prompts/abc');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
37
src/web/tests/login.test.tsx
Normal file
37
src/web/tests/login.test.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { Login } from '../src/components/Login';
|
||||
import { getToken, clearToken } from '../src/api';
|
||||
|
||||
describe('Login', () => {
|
||||
beforeEach(() => {
|
||||
clearToken();
|
||||
});
|
||||
|
||||
it('stores the pasted token and calls onLogin', () => {
|
||||
let logged = false;
|
||||
render(<Login onLogin={() => { logged = true; }} />);
|
||||
|
||||
const input = screen.getByPlaceholderText(/mcpctl_pat_/i) as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: 'mcpctl_pat_test_abc' } });
|
||||
fireEvent.click(screen.getByText(/Continue/));
|
||||
|
||||
expect(getToken()).toBe('mcpctl_pat_test_abc');
|
||||
expect(logged).toBe(true);
|
||||
});
|
||||
|
||||
it('does nothing on empty submit', () => {
|
||||
let logged = false;
|
||||
render(<Login onLogin={() => { logged = true; }} />);
|
||||
fireEvent.click(screen.getByText(/Continue/));
|
||||
expect(getToken()).toBeNull();
|
||||
expect(logged).toBe(false);
|
||||
});
|
||||
|
||||
it('toggles the help panel', () => {
|
||||
render(<Login onLogin={() => {}} />);
|
||||
expect(screen.queryByText(/mcpctl auth login/)).toBeNull();
|
||||
fireEvent.click(screen.getByText(/Where do I get a token/));
|
||||
expect(screen.getByText(/mcpctl auth login/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
1
src/web/tests/setup.ts
Normal file
1
src/web/tests/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
25
src/web/tsconfig.json
Normal file
25
src/web/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"allowImportingTsExtensions": false,
|
||||
"verbatimModuleSyntax": false,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*", "tests/**/*"]
|
||||
}
|
||||
41
src/web/vite.config.ts
Normal file
41
src/web/vite.config.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
/**
|
||||
* Vite config for the @mcpctl/web prompt editor.
|
||||
*
|
||||
* - `base: '/ui/'` so production builds work when served by mcpd at
|
||||
* `https://mcpctl.ad.itaz.eu/ui/` via @fastify/static.
|
||||
* - Dev server proxies `/api` to mcpd so the same fetch wrapper works in
|
||||
* both modes (in prod the UI is same-origin with mcpd, so no proxy needed).
|
||||
* Override the dev target via `MCPCTL_API_URL` for non-default deployments.
|
||||
* - The build artifact lands in `dist/` and is consumed by
|
||||
* `scripts/build-rpm.sh` in Stage 6.
|
||||
*/
|
||||
const apiTarget = process.env['MCPCTL_API_URL'] ?? 'https://mcpctl.ad.itaz.eu';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/ui/',
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: apiTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
chunkSizeWarningLimit: 2500,
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./tests/setup.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user