From cabe86c6c87acca4c7f9f68ccc0dadffd7b12881 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Thu, 9 Apr 2026 02:13:04 +0200 Subject: [PATCH] Align examples with project-scoped Runtime API - Use /api/v1/projects/{projectId}/sessions/... for bash e2e and React sample - Require PLANVAULT_PROJECT_ID / VITE_PLANVAULT_PROJECT_ID; add UI field - POST messages: drop autoExecute; body is { message } only (202 Accepted) - Refresh README copy (tools paths, plannerMode, removed legacy routes) - Fix formatSseText unwrapJsonStringBlob return type for tsc --noEmit Made-with: Cursor --- .env.example | 3 + README.md | 13 +++-- frontend/react-chat/README.md | 6 +- frontend/react-chat/src/App.tsx | 71 ++++++++++++++++-------- frontend/react-chat/src/formatSseText.ts | 2 +- frontend/react-chat/src/sseParse.ts | 2 +- scripts/bash-e2e/script.sh | 32 ++++++----- 7 files changed, 80 insertions(+), 49 deletions(-) diff --git a/.env.example b/.env.example index 9985208..c133a17 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,15 @@ # Runtime API (project API key from PlanVault admin UI) PLANVAULT_BASE_URL=https://api.planvault.ai PLANVAULT_API_KEY= +# UUID of the project; must match the API key (all Runtime routes use /api/v1/projects/{projectId}/…) +PLANVAULT_PROJECT_ID= # Optional: Admin API (Keycloak access token or server static admin token) for curl/scripts against /admin/* e.g. LLM providers # PLANVAULT_ADMIN_TOKEN= # React example (Vite); falls back in UI to this URL if unset VITE_PLANVAULT_BASE_URL=https://api.planvault.ai +VITE_PLANVAULT_PROJECT_ID= # Inbound webhook trigger (org-level); use with Kafka / n8n examples PLANVAULT_ORG_ID= diff --git a/README.md b/README.md index deb184e..88c5b32 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,13 @@ Copy [`.env.example`](.env.example) to `.env` and fill values. **`PLANVAULT_BASE ### Behaviour vs current PlanVault API -- **Send prompts** — use `POST /api/v1/sessions/{id}/messages` with `{"message":"...","autoExecute":true|false}` (this repo’s React sample and `scripts/bash-e2e` already do). The response includes **`messageId`** while planning runs asynchronously; poll **`GET /api/v1/sessions/{id}/messages/{messageId}/status`** for coarse phases (`planning`, `executing`, `completed`, …) or rely on SSE **`GET .../chat`** / **`GET .../history`** as before. Legacy `POST .../prompt` is deprecated. -- **HITL / plan actions** — use `POST /api/v1/sessions/{id}/actions` with `{"action":"approve"}`, `reject`, or `fill_slots` (as in `react-chat` / bash script). Legacy `POST .../approve`, `.../reject`, `.../slots` are deprecated. -- **Org tool catalog (Runtime)** — list the full org catalog with `GET /api/v1/tools?scope=org`. `GET /api/v1/tools/catalog` is deprecated; deprecated success responses may include an HTTP `Deprecation: true` header ([RFC 8594](https://datatracker.ietf.org/doc/html/rfc8594)). -- **Create session** (`POST /api/v1/sessions`): body requires **`contextVars`** (JSON object; use `{}` if none). **`externalUserId`** is optional (omit for anonymous sessions; stored as HMAC when set). Optional **`tags`**: string array on the session (case-sensitive) for admin filtering, org **GDPR export/erase by tag**, and spend/usage breakdowns in the UI. Also optional: `model`, `secrets`, `prompt`, `stream` — see **[API documentation](https://planvault.ai/api-docs)** (includes embedded Swagger). -- **Planner output:** org/project settings may set **`plannerMode`** (`auto`, `structured_json`, `python_dsl`). The Runtime SSE **`started`** / **`slots_required`** payloads include a display **`planGraph`** (`{ "nodes": [...] }` with `kind`: `assignment`, `call`, `if`, `for`, `reply`, `fail`). Clients can ignore unknown fields. -- **SSE:** besides tool and confirmation events, the server may emit **`slots_plan_summary`** (optional plain-language text shortly after **`slots_required`**, uses org/project **`utilityModel`** when set), **`run_phase`** (`selecting_tools`, `planner_llm`, …), and **`replan`**. **`GET .../history`** persists the same streamed shapes plus extra types that never appear on the live SSE connection (e.g. **`prompt`**, **`tool_selection`**, **`plan_summary`**, **`slots_cancelled`**, **`llm_reply`**). +- **Project in the URL** — all authenticated Runtime calls use **`/api/v1/projects/{projectId}/…`** (the **`projectId`** must match the project API key). There is no `X-Project-Id` header. Set **`PLANVAULT_PROJECT_ID`** for `scripts/bash-e2e` and **`VITE_PLANVAULT_PROJECT_ID`** (or the UI field) for the React sample. +- **Send prompts** — `POST /api/v1/projects/{projectId}/sessions/{id}/messages` with **`{"message":"..."}`** only. The API answers **`202 Accepted`** with **`messageId`** while planning runs asynchronously; poll **`GET /api/v1/projects/{projectId}/sessions/{id}/messages/{messageId}/status`** for coarse phases (`planning`, `executing`, `completed`, …) or rely on SSE **`GET .../sessions/{id}/chat`** / **`GET .../sessions/{id}/history`**. Legacy paths without **`projectId`** and **`POST .../prompt`** are removed on current servers. +- **HITL / plan actions** — `POST /api/v1/projects/{projectId}/sessions/{id}/actions` with `{"action":"approve"}`, `reject`, or `fill_slots` (as in `react-chat` / bash script). +- **Tools (Runtime)** — project-scoped catalog: **`GET /api/v1/projects/{projectId}/tools`**; integrations only: **`GET /api/v1/projects/{projectId}/tools?type=integration`**. Register with **`POST /api/v1/projects/{projectId}/tools`**, full replace by id with **`PUT /api/v1/projects/{projectId}/tools/{toolId}`**. Organisation-wide catalog remains under Admin **`/admin/orgs/{orgId}/tools`**. See **[API documentation](https://planvault.ai/api-docs)**. +- **Create session** — `POST /api/v1/projects/{projectId}/sessions` with optional **`contextVars`**, **`externalUserId`**, **`tags`**, **`secrets`** (see OpenAPI; this repo sends **`contextVars`** as a JSON object, often `{}`). Sending the first user turn is a separate **`POST .../messages`** call. +- **Planner output:** org/project settings set **`plannerMode`** (e.g. **`structured_json`**, **`python_dsl`**). The Runtime SSE **`started`** / **`slots_required`** payloads include a display **`planGraph`** (`{ "nodes": [...] }` with `kind`: `assignment`, `call`, `if`, `for`, `reply`, `fail`). Clients can ignore unknown fields. +- **SSE:** besides tool and confirmation events, the server may emit **`slots_plan_summary`** (optional plain-language text shortly after **`slots_required`**, uses org/project **`utilityModel`** when set), **`run_phase`** (`selecting_tools`, `planner_llm`, …), and **`replan`**. **`GET .../sessions/{id}/history`** persists the same streamed shapes plus extra types that never appear on the live SSE connection (e.g. **`prompt`**, **`tool_selection`**, **`plan_summary`**, **`slots_cancelled`**, **`llm_reply`**). - **Org LLM credentials (Admin API):** organisations can store **multiple named cloud vendor keys** — `POST /admin/orgs/{orgId}/llm/providers` with JSON **`name`**, **`vendor`**, **`apiKey`** (verify connectivity with `POST .../providers/{providerId}/verify`; update with `PATCH .../providers/{providerId}`). Model allow-list entries can use `provider` = **`cloud:{uuid}`** for those rows. **Custom backends** (your own `api_base`, e.g. Ollama or self-hosted OpenAI-compatible) are managed under `/admin/orgs/{orgId}/llm/custom-providers` and referenced as **`custom:{uuid}`**. Discover models from a custom base via `GET .../custom-providers/{providerId}/models` (not via `.../llm/catalog/...` with `custom:`). Full route tables: **[API documentation](https://planvault.ai/api-docs)**. - **HTTP errors** from the API use **RFC 7807** `application/problem+json` (e.g. admin/planner may return `type`: `urn:planvault:problem:MODEL_UNSUPPORTED_FEATURE` when structured JSON is forced but the model does not support it; deleting an org tool still referenced by saved tool scenarios without `confirmDeleteScenarios=true` may return `urn:planvault:problem:TOOL_DELETE_BLOCKED_BY_SCENARIOS`, HTTP **409**). - **Public inbound webhooks** (`POST /api/v1/orgs/.../webhooks/...`): many rejections (bad HMAC, wrong trigger, disabled trigger) return **HTTP 404** with a generic problem body — do not assume **403**. See **[API documentation](https://planvault.ai/api-docs)**. diff --git a/frontend/react-chat/README.md b/frontend/react-chat/README.md index 46d5a48..bba7122 100644 --- a/frontend/react-chat/README.md +++ b/frontend/react-chat/README.md @@ -7,17 +7,17 @@ Vite + React 18 + TypeScript. Uses `fetch` with a readable stream for SSE so the ```bash cp ../../.env.example ../../.env # optional npm install -# Defaults to https://api.planvault.ai; override: VITE_PLANVAULT_BASE_URL=... VITE_PLANVAULT_API_KEY=... npm run dev +# Defaults to https://api.planvault.ai; override: VITE_PLANVAULT_BASE_URL=... VITE_PLANVAULT_API_KEY=... VITE_PLANVAULT_PROJECT_ID=... npm run dev npm run dev ``` -Set **API base URL** and **project API key** in the UI (or via `VITE_PLANVAULT_*` in `.env`). +Set **API base URL**, **project ID** (UUID, same as the key’s project), and **project API key** in the UI (or via `VITE_PLANVAULT_*` in `.env`). **Session creation** sends required **`contextVars`** plus optional **`externalUserId`** (leave blank for an anonymous session) and optional **tags** (comma-separated; stored case-sensitively for admin/GDPR/spend tagging). The sample handles **`confirm_plan_required`** (approve/reject), **`slots_required`** (submit **`fill_slots`** via `POST .../actions`), and shows optional **`slots_plan_summary`** from SSE when the server sends it. -After **`POST .../messages`**, the UI shows **`messageId`** from the JSON body and polls **`GET .../messages/{messageId}/status`** every 2s until `completed` or `failed` (in addition to the existing SSE stream for tool/plan events). +After **`POST .../projects/{projectId}/sessions/{id}/messages`** (**HTTP 202**), the UI shows **`messageId`** from the JSON body and polls **`GET .../messages/{messageId}/status`** every 2s until `completed` or `failed` (in addition to the existing SSE stream for tool/plan events). ## Test diff --git a/frontend/react-chat/src/App.tsx b/frontend/react-chat/src/App.tsx index 89bf58d..c3a021f 100644 --- a/frontend/react-chat/src/App.tsx +++ b/frontend/react-chat/src/App.tsx @@ -27,6 +27,11 @@ function parseSlotsPayload(raw: unknown): UiSlotField[] | null { return out.length ? out : null } +function runtimeSessionsBase(root: string, projectId: string): string { + const r = root.replace(/\/$/, '') + return `${r}/api/v1/projects/${projectId.trim()}/sessions` +} + /** Prefer RFC 7807 `detail` / `title` when the API returns `application/problem+json`. */ async function formatHttpError(res: Response): Promise { const raw = await res.text() @@ -48,6 +53,7 @@ export default function App() { () => import.meta.env.VITE_PLANVAULT_BASE_URL?.trim() || 'https://api.planvault.ai', ) const [apiKey, setApiKey] = useState(() => import.meta.env.VITE_PLANVAULT_API_KEY?.trim() || '') + const [projectId, setProjectId] = useState(() => import.meta.env.VITE_PLANVAULT_PROJECT_ID?.trim() || '') const [externalUserId, setExternalUserId] = useState('demo-user') /** Comma-separated session tags (optional); sent as string[] — case-sensitive on the server. */ const [tagsText, setTagsText] = useState('react-chat-example') @@ -59,7 +65,7 @@ export default function App() { const [message, setMessage] = useState('Hello — run a short plan.') const [sendError, setSendError] = useState(null) - /** Last successful POST /messages acknowledgement (API returns messageId immediately; pipeline runs async). */ + /** Last successful POST .../messages acknowledgement (HTTP 202 body includes messageId; pipeline runs async). */ const [sendAck, setSendAck] = useState<{ status: string; messageId: string } | null>(null) /** Latest row from GET .../messages/{messageId}/status (polled until completed/failed). */ const [pipelineStatusLine, setPipelineStatusLine] = useState(null) @@ -104,6 +110,10 @@ export default function App() { setSessionError('API key is required') return } + if (!projectId.trim()) { + setSessionError('Project ID is required (must match the API key’s project UUID)') + return + } const tags = tagsText .split(',') .map((s) => s.trim()) @@ -112,8 +122,8 @@ export default function App() { const ext = externalUserId.trim() if (ext) body.externalUserId = ext if (tags.length) body.tags = tags - const root = apiBase.replace(/\/$/, '') - const res = await fetch(`${root}/api/v1/sessions`, { + const base = runtimeSessionsBase(apiBase, projectId) + const res = await fetch(base, { method: 'POST', headers: authHeaders(), body: JSON.stringify(body), @@ -145,11 +155,15 @@ export default function App() { setSlotValues({}) setSlotsPlanSummary(null) setSlotsError(null) - const root = apiBase.replace(/\/$/, '') - const res = await fetch(`${root}/api/v1/sessions/${sessionId}/messages`, { + if (!projectId.trim()) { + setSendError('Project ID is required') + return + } + const base = runtimeSessionsBase(apiBase, projectId) + const res = await fetch(`${base}/${sessionId}/messages`, { method: 'POST', headers: authHeaders(), - body: JSON.stringify({ message, autoExecute: true }), + body: JSON.stringify({ message }), }) if (!res.ok) { setSendError(await formatHttpError(res)) @@ -163,11 +177,11 @@ export default function App() { /** Poll message pipeline status (complements SSE /chat). */ useEffect(() => { - if (!sessionId || !apiKey.trim() || !sendAck?.messageId) { + if (!sessionId || !apiKey.trim() || !projectId.trim() || !sendAck?.messageId) { setPipelineStatusLine(null) return } - const root = apiBase.replace(/\/$/, '') + const base = runtimeSessionsBase(apiBase, projectId) const messageId = sendAck.messageId let cancelled = false let timeoutId: ReturnType | undefined @@ -176,7 +190,7 @@ export default function App() { if (cancelled) return try { const r = await fetch( - `${root}/api/v1/sessions/${sessionId}/messages/${encodeURIComponent(messageId)}/status`, + `${base}/${sessionId}/messages/${encodeURIComponent(messageId)}/status`, { headers: { Authorization: `Bearer ${apiKey.trim()}` } }, ) if (!r.ok || cancelled) return @@ -199,14 +213,14 @@ export default function App() { cancelled = true if (timeoutId !== undefined) clearTimeout(timeoutId) } - }, [sessionId, apiKey, apiBase, sendAck?.messageId]) + }, [sessionId, apiKey, apiBase, projectId, sendAck?.messageId]) const postAction = async (action: 'approve' | 'reject') => { - if (!sessionId) return + if (!sessionId || !projectId.trim()) return setActionBusy(true) - const root = apiBase.replace(/\/$/, '') + const base = runtimeSessionsBase(apiBase, projectId) try { - const res = await fetch(`${root}/api/v1/sessions/${sessionId}/actions`, { + const res = await fetch(`${base}/${sessionId}/actions`, { method: 'POST', headers: authHeaders(), body: JSON.stringify({ action }), @@ -222,7 +236,7 @@ export default function App() { } const submitSlots = async () => { - if (!sessionId || !pendingSlots?.length) return + if (!sessionId || !projectId.trim() || !pendingSlots?.length) return for (const s of pendingSlots) { if (!(slotValues[s.variable] ?? '').trim()) { setSlotsError(`Value required: ${s.variable}`) @@ -231,9 +245,9 @@ export default function App() { } setSlotsBusy(true) setSlotsError(null) - const root = apiBase.replace(/\/$/, '') + const base = runtimeSessionsBase(apiBase, projectId) try { - const res = await fetch(`${root}/api/v1/sessions/${sessionId}/actions`, { + const res = await fetch(`${base}/${sessionId}/actions`, { method: 'POST', headers: authHeaders(), body: JSON.stringify({ action: 'fill_slots', values: slotValues }), @@ -251,12 +265,12 @@ export default function App() { } useEffect(() => { - if (!sessionId || !apiKey.trim()) { + if (!sessionId || !apiKey.trim() || !projectId.trim()) { setStreamConn('off') return } - const root = apiBase.replace(/\/$/, '') + const base = runtimeSessionsBase(apiBase, projectId) const ac = new AbortController() setStreamConn('connecting') setStreamError(null) @@ -266,7 +280,7 @@ export default function App() { const run = async () => { try { - const res = await fetch(`${root}/api/v1/sessions/${sessionId}/chat`, { + const res = await fetch(`${base}/${sessionId}/chat`, { headers: { Authorization: `Bearer ${apiKey.trim()}`, Accept: 'text/event-stream', @@ -381,7 +395,7 @@ export default function App() { return () => { ac.abort() } - }, [sessionId, apiKey, apiBase]) + }, [sessionId, apiKey, apiBase, projectId]) return ( <> @@ -411,6 +425,17 @@ export default function App() { placeholder="Bearer project key" /> +
+ + setProjectId(e.target.value)} + placeholder="Same project as the API key" + autoComplete="off" + /> +
@@ -441,7 +466,7 @@ export default function App() { {contextError ?

{contextError}

: null}
{sessionError ?

{sessionError}

: null} {sessionId ? ( @@ -463,8 +488,8 @@ export default function App() {