Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba571999a2 | ||
|
|
594314d351 | ||
|
|
c709b1f96a | ||
|
|
54d6ce9959 | ||
|
|
8a7ae9be23 | ||
|
|
19cebcb92a | ||
|
|
8b0a96c952 | ||
|
|
ad9789b072 | ||
|
|
7e4e052694 | ||
|
|
3cbaf7beb6 | ||
|
|
298accf659 | ||
|
|
12d061f579 | ||
|
|
ff87cdbd98 | ||
|
|
0f6cec3315 |
+50
-47
@@ -1,48 +1,51 @@
|
||||
# console2 — Hanzo Cloud Console (Next.js 15 + @hanzo/gui). MIT OR Apache-2.0.
|
||||
# NEXT_PUBLIC_* are inlined at build time (browser config), so they are build args.
|
||||
FROM public.ecr.aws/docker/library/node:24-alpine AS build
|
||||
WORKDIR /app
|
||||
# Exact commit for a deterministic Next build id (next.config.mjs generateBuildId).
|
||||
# The alpine image has no git binary, so CI passes the SHA as a build arg -> ENV,
|
||||
# baked into .next/BUILD_ID so every replica of this image shares ONE build id.
|
||||
ARG SOURCE_COMMIT=""
|
||||
ENV SOURCE_COMMIT=$SOURCE_COMMIT
|
||||
# Copy ALL source FIRST, then install — order matters under Kaniko --single-snapshot:
|
||||
# a `COPY` that FOLLOWS the install in the same stage drops that RUN's freshly
|
||||
# created node_modules (the 'next not found' cause — the install's own `test -f next`
|
||||
# passed, then `COPY . .` wiped node_modules before the build RUN). Putting COPY
|
||||
# before install means node_modules is created by the LAST RUNs and nothing clobbers
|
||||
# it. (Layer-cache for deps is moot here — the on-cluster build runs --cache=false.)
|
||||
COPY . .
|
||||
# public/ may be empty (git doesn't track empty dirs) — ensure it exists for the runner COPY.
|
||||
RUN mkdir -p public
|
||||
# corepack installs the exact pnpm from package.json's `packageManager`, so the
|
||||
# builder and a laptop resolve identically. --frozen-lockfile is the whole reason
|
||||
# this repo is on pnpm: the old `npm install` here could not be `npm ci`, because
|
||||
# @hanzo/gui's react-native tree resolves its platform/optional packages differently
|
||||
# across npm versions and a lockfile written by one npm failed under another. pnpm
|
||||
# records every platform in the lockfile, so the build installs exactly what is
|
||||
# committed and fails loudly instead of quietly resolving something else.
|
||||
RUN corepack enable && pnpm install --frozen-lockfile
|
||||
# ONE brand-agnostic image: brand (IAM org/issuer/app + wordmark) is resolved at
|
||||
# RUNTIME from the request hostname (src/config/index.ts), and /v1 is same-origin
|
||||
# per host. Baking NEXT_PUBLIC_* here would inline a single brand and break that.
|
||||
# Next 15 + @hanzo/gui (large RN dep tree) overflows Node's default heap → OOMKill
|
||||
# (exit 137); cap the heap generously (chat uses 4096).
|
||||
ENV NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=--max-old-space-size=6144
|
||||
RUN pnpm build
|
||||
# hanzoai/console — the console image. It serves itself.
|
||||
#
|
||||
# The console is a static SPA export; this puts hanzoai/static in front of it. That
|
||||
# itself: hanzoai/static in front of the bundle. It exists so a console change
|
||||
# can reach production without a cloud release.
|
||||
#
|
||||
# Today console.hanzo.ai is answered by the cloud binary, which go:embeds the
|
||||
# bundle (webui/console.go `//go:embed all:dist`). That couples a frontend change
|
||||
# to a backend release: the bundle must be published, its tag pinned in cloud's
|
||||
# Dockerfile, and a whole cloud image rebuilt and rolled out. The pin commit that
|
||||
# preceded this one says what that costs — "four changes that could not reach
|
||||
# production".
|
||||
#
|
||||
# Nothing about the request path changes when this serves instead. The embedded
|
||||
# console is already a static export talking to the SAME origin's /v1, and cloud's
|
||||
# catch-all only ever answered paths that no API route claimed (its apiPrefixes
|
||||
# list is exactly "/v1/", "/api/", "/zap", "/healthz", "/readyz"). So the split is
|
||||
# the one the ingress already expresses for admin.lux.cloud: /v1 + /zap to cloud,
|
||||
# everything else here. Same bytes, same origin, same cookie — one fewer release
|
||||
# in the way.
|
||||
#
|
||||
# -spa, not a 404 page: every unknown path IS a client-side route for an app shell
|
||||
# (/models, /billing/budgets, a deep link someone pasted). The marketing site takes
|
||||
# the opposite setting for the opposite reason — there a miss is a mistake.
|
||||
|
||||
FROM public.ecr.aws/docker/library/node:24-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=4000
|
||||
RUN addgroup -S app && adduser -S app -G app
|
||||
COPY --from=build /app/.next ./.next
|
||||
COPY --from=build /app/public ./public
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/package.json ./package.json
|
||||
COPY --from=build /app/next.config.mjs ./next.config.mjs
|
||||
# next.config.mjs imports this at load time (build AND standalone runtime); copy it or the server ERR_MODULE_NOT_FOUND-crashes on boot.
|
||||
COPY --from=build /app/src/config/build-id.mjs ./src/config/build-id.mjs
|
||||
USER app
|
||||
EXPOSE 4000
|
||||
CMD ["node", "node_modules/next/dist/bin/next", "start", "-p", "4000"]
|
||||
FROM public.ecr.aws/docker/library/node:24-alpine AS build
|
||||
RUN apk add --no-cache git
|
||||
WORKDIR /console
|
||||
# Heap headroom so the full @hanzo/gui static export never OOMs into a stub; telemetry off.
|
||||
ENV NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=--max-old-space-size=8192
|
||||
# The console.hanzo.ai analytics property (public per-site id, not a KMS secret) —
|
||||
# the same default Dockerfile.embed bakes, so a bundle served from here reports
|
||||
# identically to one served from inside cloud.
|
||||
ARG NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=7dce54ee-41f6-4751-96bf-fe005067c7c7
|
||||
ENV NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=$NEXT_PUBLIC_ANALYTICS_WEBSITE_ID
|
||||
COPY . .
|
||||
RUN corepack enable && pnpm install --frozen-lockfile
|
||||
# FAIL-HARD: the export MUST emit a real bundle, never a placeholder shell. An
|
||||
# empty index.html would serve a blank page on every route with a 200, which is
|
||||
# indistinguishable from a working deploy until someone opens it.
|
||||
RUN pnpm build:embed && [ -s out/index.html ] && [ -d out/_next ] \
|
||||
&& echo ">> servable REAL console bundle: $(wc -c < out/index.html)-byte index.html, $(du -sh out/_next | cut -f1) _next/"
|
||||
|
||||
# hanzoai/static, digest-pinned: a base image is pinned by digest so the bytes
|
||||
# cannot change under a rebuild. (The console's OWN release is named by semver in
|
||||
# the values file — that is the version a human reads.)
|
||||
FROM ghcr.io/hanzoai/static@sha256:346ad30dc7f762c508b4467c2801b3d7e9ec201ec9b257bc7a38b60d59cecc05
|
||||
COPY --from=build /console/out/ /srv/
|
||||
EXPOSE 3000
|
||||
ENTRYPOINT ["/static"]
|
||||
CMD ["-root=/srv", "-spa", "-port=3000"]
|
||||
|
||||
@@ -3588,8 +3588,8 @@ and the rail hid them.
|
||||
rail, `SubNav`, and ⌘K all say the same word. The eight products missing `subpages`
|
||||
now declare them; the icons the strips were carrying moved onto the declarations.
|
||||
- **`components/ui/SubNav.tsx` is the ONE strip**, rendered from `productSubpages` and
|
||||
hidden at `lg+` (`$lg={{ display: 'none' }}`) because the sidebar's `DrillNav` owns
|
||||
level 2 there. One declaration, two mounts — never two navs painting at once. It
|
||||
hidden at `lg+` (`$lg={{ display: 'none' }}`) because the sidebar owns level 2
|
||||
there (then `DrillNav`; now `SubRows` — see "The rail stopped drilling" below). One declaration, two mounts — never two navs painting at once. It
|
||||
takes an optional `href` for a product whose tabs carry URL state (Containers keeps
|
||||
its `?cluster=` selection across tabs). `subpageIcon` moved here and `dashboard.tsx`
|
||||
imports it, so the sub-page icon defaults exist once.
|
||||
@@ -3833,3 +3833,91 @@ reports signed out and the app sits on its loader forever. It emits base64URL no
|
||||
which is what a JWT segment actually is. And the assistant's composer carries its own
|
||||
mic with the same `Talk to Hanzo` label, mounted-but-hidden until the panel opens, so
|
||||
a bare attribute locator matches that one first: scope to `getByTestId('assistant-fab')`.
|
||||
|
||||
|
||||
## The agent quickstart, and the rail that stopped drilling (v8.5.62)
|
||||
|
||||
Two changes that share a shape: something that looked finished was standing in for
|
||||
the thing itself.
|
||||
|
||||
**The builder had no way in.** `AgentBuilder` — the canonical, host-agnostic one —
|
||||
was reachable only as a form in a side pane, from a board you first had to have
|
||||
agents to be looking at. `agents/quickstart` is the way someone with none starts:
|
||||
describe what you want in a sentence, or take a template, then configure, run and
|
||||
integrate.
|
||||
|
||||
- **Every step is an endpoint**, which is the whole design constraint. Describe →
|
||||
`POST /v1/chat/completions` (`draftAgent`) turns a sentence into a spec; Configure →
|
||||
the SAME `AgentBuilder`, seeded; Run → `POST /v1/agents/:ref/run` executes it and
|
||||
shows the RECORDED run; Integrate → prints the request that just worked. A ladder
|
||||
of steps is a promise about what happens, and a step that only draws a checkmark
|
||||
turns the promise into decoration. Steps 1 and 3 are optional by construction —
|
||||
their loaders may be absent, and the step then says exactly what is missing.
|
||||
- **`components/agent-builder/templates.ts`** — eight presets, pure data. A template is
|
||||
a PRESET, never a promise: it may only carry fields `toCreateBody` already expresses
|
||||
(`name`, `description`, `systemPrompt`, and the real `AgentConfig` knobs), and a test
|
||||
pins exactly that. **None names a tool.** Tools are per-org, so a hardcoded
|
||||
`web.search` would name something that may not exist and would fail at the agent's
|
||||
FIRST invocation rather than in the form. What a template CAN say truthfully is
|
||||
`useTools` / `webSearch`, which are real switches in the agent contract.
|
||||
- **The tool plane was live the whole time.** `loaders.ts` said "No live tool catalog
|
||||
endpoint on this deployment yet" and left the field typeable-only. `GET /v1/tools` is
|
||||
bound and serving — one flat set spanning connector actions, functions, zap-service
|
||||
routes, agents, skills and the org's own MCP servers, deduplicated by name, each
|
||||
flagged `activated`. `lib/api/tools.ts` reads it, `proxy-allow` admits the head, and
|
||||
`/v1/tools/call` is REFUSED there: running a tool belongs to whatever runs an agent,
|
||||
never to a browser tab. An org with nothing activated gets `{"tools":[]}` — a real
|
||||
empty answer, shown honestly rather than papered over.
|
||||
- **`defaultModel` was picking an embeddings model.** It named `zen-omni` as its exact
|
||||
match, and the live catalog does not carry that id — so the exact arm never fired and
|
||||
the fallback ran instead: `^zen[-.]` over an alphabetically sorted catalog, which
|
||||
selects `zen-embedding`. Every agent created without touching the model field was
|
||||
pointed at a SKU that cannot hold a conversation, and nothing caught it because the
|
||||
dead exact-match read like the rule. The family test is `^zen\d` now, because zen's
|
||||
naming splits cleanly: **`zen5*` are the text models; `zen-<noun>` names a MODALITY**
|
||||
(embedding, image, video, rerank, voice, vl, guard). The model and tool placeholders
|
||||
were advertising the same dead id and two invented tool names; both now say things
|
||||
that exist.
|
||||
|
||||
**The rail stopped drilling.** Clicking a product used to swap the ENTIRE sidebar for
|
||||
that product's sub-nav, behind a "Back to all products" button. The options were
|
||||
identical either way — what the drill took away was every OTHER product, which is
|
||||
precisely what someone needs when the reason they opened the rail was to go somewhere
|
||||
else. `SubRows` replaces `DrillNav`: a product's sub-pages expand beneath its own row,
|
||||
indented on a hairline, `inert` when collapsed.
|
||||
|
||||
- **The label navigates; the chevron only opens and closes.** One target doing both
|
||||
would make "show me what is in here" and "take me there" the same gesture.
|
||||
- `productIsOpen` / `toggleProduct` in `nav-accordion.ts`, beside the category pair and
|
||||
keyed apart from it. The default is the OPPOSITE of a category's, deliberately:
|
||||
categories are few and describe the catalog, so they open; products are many and each
|
||||
brings four to eight rows, so opening them all would bury the catalog under its own
|
||||
detail. The product you are IN is open unless you closed it, and that choice persists.
|
||||
- **A pinned product appears twice** — once under Pinned, once in its category — and
|
||||
exactly ONE copy may carry the sub-list. Two copies is two navs painting at once,
|
||||
which is the thing this rail exists to avoid, and it doubles the rail's height for no
|
||||
information. The pinned copy owns it.
|
||||
|
||||
**Verification.** `tsc --noEmit` clean; `vitest` **3259 passed / 8 skipped** (262 files;
|
||||
+draft/handle parsing, +templates, +the product accordion, +the tool-plane allow/refuse
|
||||
pair, and a `defaultModel` test that goes red on the `zen-embedding` regression).
|
||||
RENDER-proven: `e2e/agent-quickstart.spec.ts` (3 tests) asserts the ladder, that the
|
||||
gallery sits to the RIGHT of the composer by measured geometry at 1440, that searching
|
||||
narrows it, that picking Deep researcher carries its handle and prompt into step 2, and
|
||||
that 390 stacks without the body scrolling sideways. `e2e/level-2-nav.spec.ts` (5/5) was
|
||||
retargeted, not deleted: it now asserts "All products" is still on screen while a
|
||||
product is open — the invariant the drill could never have satisfied — and that no
|
||||
"Back to all products" button exists on any of the 18 converted products.
|
||||
|
||||
**ONE door.** The board's New-Agent button opened the builder in a side pane —
|
||||
the same component, reached by a different shape, with no templates, no drafting,
|
||||
and nowhere to run what it made. It goes to the quickstart now and
|
||||
`NewAgentForm` is deleted; two entrances to one builder is two things to keep in
|
||||
step, and the pane was the lesser of them. A spec clicks the board's CTA and
|
||||
asserts the URL lands on `/agents/quickstart`.
|
||||
|
||||
One placement note that cost a debug cycle: the quickstart branch must return BEFORE
|
||||
`AgentsModule`'s loading/error/empty states. Building an agent does not depend on
|
||||
reading the ones that exist, and the moments you most need the quickstart — no agents
|
||||
yet, or the registry not answering — are exactly the ones those early returns swallow
|
||||
it in.
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* e2e: the agent quickstart.
|
||||
*
|
||||
* The surface in the screenshot: a step ladder, "What do you want to build?" with a
|
||||
* composer, and a searchable template gallery beside it. These are assertions only a
|
||||
* browser can make — that the two columns actually paint side by side at desktop,
|
||||
* stack on a phone without the body scrolling sideways, and that picking a template
|
||||
* carries its preset into the builder.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test agent-quickstart
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
|
||||
requireFixtureServer()
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
const ACCOUNT = { owner: 'hanzo', name: 'z', email: 'z@hanzo.ai', displayName: 'Z Admin', isAdmin: true }
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
const json = (route: Route, body: unknown, status = 200) =>
|
||||
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
|
||||
/** Every backend 401s — this spec is about the SURFACE, not data. */
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
if (url.pathname.startsWith('/auth/')) return json(route, { ok: true })
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(url.pathname)) return route.continue()
|
||||
return json(route, { error: 'Sign in to use Hanzo Cloud.' }, 401)
|
||||
}
|
||||
|
||||
async function open(page: Page) {
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, ACCOUNT)
|
||||
await page.goto(`${BASE_URL}/agents/quickstart`, { waitUntil: 'domcontentloaded' })
|
||||
await page.locator('[data-testid="product-content"]').first().waitFor({ state: 'attached', timeout: 30_000 })
|
||||
await page.waitForTimeout(1500)
|
||||
}
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
test('desktop: the ladder, the composer and the gallery', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await open(page)
|
||||
|
||||
await expect(page.getByText('What do you want to build?')).toBeVisible()
|
||||
await expect(page.getByLabel('Describe your agent')).toBeVisible()
|
||||
await expect(page.getByText('Browse templates')).toBeVisible()
|
||||
|
||||
// Step 1 is current; later steps are present but not yet reachable.
|
||||
await expect(page.getByRole('button', { name: /Step 1: Describe/ })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /Step 3: Run/ })).toBeDisabled()
|
||||
|
||||
// The two columns sit SIDE BY SIDE — geometry, not source.
|
||||
const composer = await page.getByLabel('Describe your agent').boundingBox()
|
||||
const gallery = await page.getByText('Browse templates').boundingBox()
|
||||
expect(composer && gallery).toBeTruthy()
|
||||
expect(gallery!.x, 'the gallery is to the right of the composer').toBeGreaterThan(composer!.x + composer!.width - 1)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'agent-quickstart-desktop.png'), fullPage: false })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('the gallery searches, and picking a template carries its preset into the builder', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await open(page)
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Start from Deep researcher' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Start from Code reviewer' })).toBeVisible()
|
||||
|
||||
await page.getByLabel('Search templates').fill('extract')
|
||||
await page.waitForTimeout(400)
|
||||
await expect(page.getByRole('button', { name: 'Start from Structured extractor' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Start from Deep researcher' })).toHaveCount(0)
|
||||
|
||||
await page.getByLabel('Search templates').fill('')
|
||||
await page.waitForTimeout(300)
|
||||
await page.getByRole('button', { name: 'Start from Deep researcher' }).click()
|
||||
await page.waitForTimeout(700)
|
||||
|
||||
// Step 2: the ONE builder, carrying the template's preset — the handle and the
|
||||
// prompt the template declares, not an empty form.
|
||||
await expect(page.getByRole('button', { name: /Step 2: Configure/ })).toBeVisible()
|
||||
await expect(page.locator('input[value="researcher"]').first()).toBeVisible()
|
||||
await expect(page.getByText(/You research questions/).first()).toBeVisible()
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'agent-quickstart-configure.png'), fullPage: false })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('phone: it stacks and the body never scrolls sideways', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
|
||||
const page = await ctx.newPage()
|
||||
await open(page)
|
||||
|
||||
await expect(page.getByText('What do you want to build?')).toBeVisible()
|
||||
await expect(page.getByLabel('Describe your agent')).toBeVisible()
|
||||
|
||||
const scrolls = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
|
||||
)
|
||||
expect(scrolls, 'body must not scroll horizontally').toBe(false)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'agent-quickstart-phone.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('a template card is reachable and operable by keyboard, and it rings', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await open(page)
|
||||
|
||||
const card = page.getByRole('button', { name: 'Start from Deep researcher' })
|
||||
await card.focus()
|
||||
await expect(card).toBeFocused()
|
||||
|
||||
// The focus law lives in globals.css and keys off [tabindex] among others — a card
|
||||
// that takes focus and shows nothing is worse than one that cannot be reached.
|
||||
const ring = await card.evaluate((el) => {
|
||||
const s = getComputedStyle(el)
|
||||
return { width: s.outlineWidth, style: s.outlineStyle, color: s.outlineColor }
|
||||
})
|
||||
expect(ring.style, 'the focused card draws an outline').not.toBe('none')
|
||||
expect(parseFloat(ring.width), 'the outline has real width').toBeGreaterThan(0)
|
||||
|
||||
// Enter picks it — the same thing a click does.
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(700)
|
||||
await expect(page.getByRole('button', { name: /Step 2: Configure/ })).toBeVisible()
|
||||
await expect(page.locator('input[value="researcher"]').first()).toBeVisible()
|
||||
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('the board\'s New Agent button is the SAME door as the quickstart', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, ACCOUNT)
|
||||
await page.goto(`${BASE_URL}/agents`, { waitUntil: 'domcontentloaded' })
|
||||
await page.locator('[data-testid="product-content"]').first().waitFor({ state: 'attached', timeout: 30_000 })
|
||||
await page.waitForTimeout(1200)
|
||||
|
||||
// Whichever New-Agent affordance the board is showing (header button or empty
|
||||
// state), it must LAND on the quickstart — not open a second, differently-shaped
|
||||
// create form in a side pane.
|
||||
const cta = page.getByRole('button', { name: /New Agent/i }).filter({ visible: true }).first()
|
||||
await cta.click()
|
||||
await page.waitForTimeout(900)
|
||||
|
||||
expect(new URL(page.url()).pathname).toBe('/agents/quickstart')
|
||||
await expect(page.getByText('What do you want to build?')).toBeVisible()
|
||||
|
||||
await ctx.close()
|
||||
})
|
||||
+24
-13
@@ -2,9 +2,15 @@
|
||||
* e2e: ONE level-2 nav.
|
||||
*
|
||||
* Clicking into a product must reveal ITS options rather than replacing the screen,
|
||||
* and there must be exactly ONE such nav on screen — not the sidebar's drill-down AND
|
||||
* a competing tab strip in the content, which is what `/models` used to do (eight
|
||||
* items in the rail, four in the content, disagreeing on the index's own name).
|
||||
* and there must be exactly ONE such nav on screen — not the sidebar's level 2 AND a
|
||||
* competing tab strip in the content, which is what `/models` used to do (eight items
|
||||
* in the rail, four in the content, disagreeing on the index's own name).
|
||||
*
|
||||
* "Rather than replacing the screen" is now literal on both axes: the product's
|
||||
* sub-pages expand BENEATH its row and the rest of the catalog stays put. The rail
|
||||
* used to swap itself for the product's sub-nav behind a "Back to all products"
|
||||
* button, so these specs assert the other products are still there — that is the
|
||||
* whole point of the change, and the part a future drill would silently undo.
|
||||
*
|
||||
* These are assertions only a browser can make. They read COMPUTED style and
|
||||
* GEOMETRY, not source: a strip hidden by a `$lg` media style prop is still in the
|
||||
@@ -76,8 +82,12 @@ test('desktop: the sidebar owns level 2 — the content strip is not a second na
|
||||
const page = await ctx.newPage()
|
||||
await open(page, '/models')
|
||||
|
||||
// The rail drilled into Models and shows the product's own options.
|
||||
await expect(page.getByRole('button', { name: 'Back to all products' })).toBeVisible()
|
||||
// The rail expanded Models in place — and did NOT swap itself for it.
|
||||
await expect(page.getByRole('button', { name: 'Back to all products' })).toHaveCount(0)
|
||||
// The rest of the catalog is still there — "All products" sits at the FOOT of the
|
||||
// product list, so its presence proves the list was never swapped away. This is the
|
||||
// assertion the drill could not have passed.
|
||||
await expect(page.getByRole('button', { name: 'All products' }).first()).toBeVisible()
|
||||
|
||||
// The index is named what the PRODUCT calls it — Models' index is the Catalog,
|
||||
// not a generic "Overview". This is the registry's `indexLabel`, read by the nav.
|
||||
@@ -166,10 +176,10 @@ test('back returns a level without losing pinned state', async ({ browser }) =>
|
||||
await page.waitForTimeout(900)
|
||||
expect(new URL(page.url()).pathname).toBe('/models')
|
||||
|
||||
// Still drilled into Models with the same options — Back moved the LEVEL, it did
|
||||
// not throw the user out to the product list.
|
||||
await expect(page.getByRole('button', { name: 'Back to all products' })).toBeVisible()
|
||||
// Models is still expanded with the same options — browser Back moved the ROUTE,
|
||||
// and the rail followed it without collapsing what the user was looking at.
|
||||
await expect(visibleTab(page, 'Catalog').first()).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Back to all products' })).toHaveCount(0)
|
||||
|
||||
expect(await page.evaluate(() => localStorage.getItem('hanzo.preferences.cache'))).toBe(pinsBefore)
|
||||
await ctx.close()
|
||||
@@ -177,9 +187,10 @@ test('back returns a level without losing pinned state', async ({ browser }) =>
|
||||
|
||||
/**
|
||||
* Every product that used to carry its own `const TABS` — the whole conversion, in
|
||||
* one sweep. For each: the page renders, the rail drills into it, and the content
|
||||
* strip is present but PAINTS NOTHING at lg+. That is the "no second nav" invariant,
|
||||
* and it is the thing that regresses the moment someone adds a tab bar back.
|
||||
* one sweep. For each: the page renders, the rail expands it in place, and the
|
||||
* content strip is present but PAINTS NOTHING at lg+. That is the "no second nav"
|
||||
* invariant, and it is the thing that regresses the moment someone adds a tab bar
|
||||
* back.
|
||||
*/
|
||||
const CONVERTED = [
|
||||
'models', 'evals', 'ai-accounts', 'containers', 'analytics', 'finetuning', 'team',
|
||||
@@ -200,8 +211,8 @@ test('no product paints a second level-2 nav at lg+', async ({ browser }) => {
|
||||
).toBe('none')
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Back to all products' }),
|
||||
`${id}: the rail drilled in`,
|
||||
).toBeVisible()
|
||||
`${id}: the rail expands in place — it must never swap itself for one product`,
|
||||
).toHaveCount(0)
|
||||
}
|
||||
|
||||
await ctx.close()
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Playground layout: the Response panel sits UNDER the surface tabs — above
|
||||
* the composer — at every width. Render-proven on the local dev server with a
|
||||
* fully mocked network (no gateway, no billing, no catalog): what is asserted
|
||||
* is GEOMETRY, which mocks cannot fake.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test playground-responsive
|
||||
*/
|
||||
import { test, expect, type Route } from '@playwright/test'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
|
||||
// The module shell resolves the product registry from the local fixture server,
|
||||
// like every other module render spec; skip cleanly when it is down.
|
||||
requireFixtureServer()
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
const WIDTHS = [
|
||||
{ name: 'phone', width: 390, height: 844 },
|
||||
{ name: 'tablet', width: 834, height: 1112 },
|
||||
{ name: 'laptop', width: 1440, height: 900 },
|
||||
{ name: 'desktop', width: 1920, height: 1080 },
|
||||
]
|
||||
|
||||
// Minimal honest bodies for everything the page asks the backend.
|
||||
const mock = async (route: Route) => {
|
||||
const url = route.request().url()
|
||||
const json = (body: unknown) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
if (url.includes('/pricing/models')) return json({ models: [] })
|
||||
if (url.includes('/v1/models'))
|
||||
return json({ object: 'list', data: [{ id: 'zen5-flash', owned_by: 'Hanzo' }] })
|
||||
if (url.includes('/billing/subscriptions')) return json({ subscriptions: [] })
|
||||
if (url.includes(':4000') || url.startsWith(BASE_URL)) return route.continue()
|
||||
return json({})
|
||||
}
|
||||
|
||||
for (const vp of WIDTHS) {
|
||||
test(`response renders under the tabs at ${vp.name} (${vp.width}px)`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: vp.width, height: vp.height })
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page)
|
||||
await page.goto(`${BASE_URL}/ai/playground`, { waitUntil: 'domcontentloaded' })
|
||||
|
||||
// The three landmarks: the surface tabs, the Response panel, the composer.
|
||||
const tabs = page.getByRole('button', { name: 'Completions' }).first()
|
||||
const response = page.getByText('Response', { exact: true }).first()
|
||||
const composer = page.getByText('System prompt', { exact: true }).first()
|
||||
await expect(tabs).toBeVisible({ timeout: 20000 })
|
||||
await expect(response).toBeVisible()
|
||||
await expect(composer).toBeVisible()
|
||||
|
||||
const [tabsBox, respBox, compBox] = await Promise.all([
|
||||
tabs.boundingBox(),
|
||||
response.boundingBox(),
|
||||
composer.boundingBox(),
|
||||
])
|
||||
if (!tabsBox || !respBox || !compBox) throw new Error('a landmark has no box')
|
||||
|
||||
// ORDER: tabs, then Response, then the composer — at every width.
|
||||
expect(respBox.y, 'Response sits below the tabs').toBeGreaterThan(tabsBox.y)
|
||||
expect(compBox.y, 'the composer sits below the Response panel top').toBeGreaterThan(respBox.y)
|
||||
|
||||
// RESPONSIVE: nothing forces a horizontal scroll.
|
||||
const scrollW = await page.evaluate(() => document.documentElement.scrollWidth)
|
||||
expect(scrollW, 'no horizontal overflow').toBeLessThanOrEqual(vp.width + 1)
|
||||
|
||||
mkdirSync(SHOTS, { recursive: true })
|
||||
await page.screenshot({ path: join(SHOTS, `playground-${vp.name}-${vp.width}.png`) })
|
||||
})
|
||||
}
|
||||
@@ -1,34 +1,42 @@
|
||||
# Canonical CI config for hanzoai/console — read by the hanzoai/ci reusable
|
||||
# (.hanzo/workflows/cicd.yml) and platform.hanzo.ai.
|
||||
# (.hanzo/workflows/cicd.yml) and platform.hanzo.ai. hanzoai/ci pushes to `repo:`
|
||||
# (GHCR) and server-side-mirrors to registry.hanzo.ai automatically.
|
||||
#
|
||||
# Publishes the console STATIC EMBED artifact (SPA static export at /dist) as a
|
||||
# versioned immutable image. hanzoai/cloud consumes it via `FROM ... AS console`
|
||||
# + `COPY --from=console /dist/`, so it never rebuilds npm+Next on a cloud release.
|
||||
# hanzoai/ci pushes to `repo:` (GHCR) and server-side-mirrors to registry.hanzo.ai
|
||||
# automatically.
|
||||
# TWO artifacts, one bundle. The console is a static SPA export; what differs is
|
||||
# only who serves it:
|
||||
#
|
||||
# BOTH console images are declared here — one config, read by whichever runner
|
||||
# executes it. The Next.js SERVER image (admin.hanzo.ai, operator CR
|
||||
# universe:crs/console.yaml) was built by .github/workflows/build-image.yml until
|
||||
# that file was neutralized on 2026-07-24 in favour of a native pipeline that
|
||||
# could not run: hanzoai/console had the forge Actions unit DISABLED
|
||||
# (`has_actions: false`, zero runs), so nothing built it — v8.5.23 and 8.5.24
|
||||
# shipped no image, and the CR still pins the last one built, v8.5.22. Declaring
|
||||
# both images here puts them on the ONE pipeline, wherever it executes.
|
||||
# console-embed the bundle alone at /dist. hanzoai/cloud does
|
||||
# `COPY --from=console /dist/` so a cloud release never rebuilds
|
||||
# npm+Next. Needed only while cloud go:embeds the console.
|
||||
# console the bundle behind hanzoai/static, serving itself. This is how
|
||||
# a console change ships WITHOUT a cloud release: move image.tag
|
||||
# in a universe values file and cd rolls it.
|
||||
#
|
||||
# Tag shape changes with the builder, deliberately: the shared builder publishes
|
||||
# the immutable `sha-<sha7>-amd64` per main push (plus the bare semver on a v*
|
||||
# tag), not the `:v<X.Y.Z>` receipt the old bespoke workflow minted. Pin the CR to
|
||||
# the sha tag — that is what hanzoai/cloud does, and an immutable digest-shaped
|
||||
# tag cannot be re-pushed to different bytes the way `:v8.4.118` once was.
|
||||
# The Next.js SERVER image that used to be the second entry is gone. It was
|
||||
# already doing nothing a file server could not — every host it served sent /v1
|
||||
# and /zap to cloud-api at the ingress, so its BFF was never reached — and its own
|
||||
# auth routes stopped mattering when identity became a client-held IAM token.
|
||||
#
|
||||
# TAGS: the shared builder publishes `sha-<sha7>-amd64` on every main push AND the
|
||||
# bare semver on a cut v* tag. PIN THE SEMVER — it says which console RELEASE a
|
||||
# deployment carries, which a sha cannot. The discipline that keeps that honest is
|
||||
# that a cut tag is never re-pointed (`:v8.4.118` once was): cut the next patch
|
||||
# instead.
|
||||
images:
|
||||
- name: console-embed
|
||||
context: .
|
||||
dockerfile: Dockerfile.embed
|
||||
repo: ghcr.io/hanzoai/console-embed
|
||||
# The brand-agnostic Next.js server image: brand resolves at RUNTIME from the
|
||||
# request hostname, so no NEXT_PUBLIC_* may be baked (baking pins the image to
|
||||
# one brand). SOURCE_COMMIT is the only build arg it ever took.
|
||||
# The console. It is static — that is not a variant, it is what the console IS,
|
||||
# so the image is `console` and there is no adjective in the name. Dockerfile
|
||||
# builds the SPA export and puts hanzoai/static in front of it.
|
||||
#
|
||||
# This REPLACES the Next.js server image that used to be published here. It was
|
||||
# already doing nothing a file server could not: every host it serves
|
||||
# (admin.lux.cloud, admin.lux.network, admin.zoo.cloud) sends /v1 and /zap to
|
||||
# cloud-api at the ingress, so the server's BFF at /v1/* was never reached on any
|
||||
# of them. Its own auth routes went the same way when identity became a
|
||||
# client-held IAM token. One console, one image.
|
||||
- name: console
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "@hanzo/console",
|
||||
"version": "8.5.50",
|
||||
"version": "8.5.62",
|
||||
"packageManager": "pnpm@11.17.0",
|
||||
"private": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"author": "Hanzo AI <dev@hanzo.ai>",
|
||||
"description": "Hanzo Cloud Console \u2014 unified admin console for Hanzo Cloud and all cloud products.",
|
||||
"description": "Hanzo Cloud Console — unified admin console for Hanzo Cloud and all cloud products.",
|
||||
"scripts": {
|
||||
"dev": "next dev -p 4000",
|
||||
"build": "next build",
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Button, Input, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Activity, Plus, Search, Star } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { visibleCatalogByCategory, type CatalogEntry, type ProductIcon } from '~/lib/products/registry'
|
||||
import { useAppsBeta } from '~/lib/products/beta'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { usePins, useProductColors } from '~/lib/products/pins'
|
||||
import { openProduct } from '~/lib/products/open'
|
||||
@@ -189,7 +190,8 @@ export function AddProductPanel() {
|
||||
|
||||
// Source = the FULL catalog the viewer may see (ungated → both pinned and unpinned
|
||||
// appear), grouped by category.
|
||||
const groups = useMemo(() => visibleCatalogByCategory(showAdmin, null), [showAdmin])
|
||||
const showBeta = useAppsBeta(showAdmin)
|
||||
const groups = useMemo(() => visibleCatalogByCategory(showAdmin, null, showBeta), [showAdmin, showBeta])
|
||||
|
||||
// Literal, case-insensitive substring match over label/description/id — NOT a
|
||||
// compiled RegExp of user input.
|
||||
|
||||
@@ -77,6 +77,7 @@ import { assistantState, commandBarSystemPrompt, hanzoAssistantSystemPrompt } fr
|
||||
import { searchDestinations, type Destination } from '~/lib/products/search'
|
||||
import { DEFAULT_GROUP_LABEL, pinnedFirst } from '~/lib/products/pins-core'
|
||||
import { usePins, useProductColors } from '~/lib/products/pins'
|
||||
import { useAppsBeta } from '~/lib/products/beta'
|
||||
import { ProductIcon } from '~/components/ui/ProductIcon'
|
||||
import { openProduct } from '~/lib/products/open'
|
||||
import { currentOrg, switchOrg } from '~/lib/org-scope'
|
||||
@@ -364,6 +365,7 @@ function PaletteDialog({
|
||||
const router = useRouter()
|
||||
const { signOut } = useSession()
|
||||
const showAdmin = useIsSuperAdmin()
|
||||
const showBeta = useAppsBeta(showAdmin)
|
||||
const { colorOf } = useProductColors()
|
||||
const pins = usePins()
|
||||
const { current, resolvedTheme, set: setTheme } = useThemeSetting()
|
||||
@@ -439,7 +441,7 @@ function PaletteDialog({
|
||||
// a search, so the ranked branch is left strictly alone.
|
||||
const destResults = useMemo(() => {
|
||||
if (mode !== 'catalog') return []
|
||||
const found = searchDestinations(query, showAdmin, null)
|
||||
const found = searchDestinations(query, showAdmin, null, showBeta)
|
||||
if (sub) return found.slice(0, 50)
|
||||
return pinnedFirst(found, (d) => (d.kind === 'product' ? d.entry.id : ''), pins.pinnedIds)
|
||||
}, [mode, query, sub, showAdmin, pins.pinnedIds])
|
||||
|
||||
@@ -105,9 +105,31 @@ export function ContextSwitcher() {
|
||||
iconAfter={<ChevronsUpDown size={13} opacity={0.6} />}
|
||||
aria-label={`Organization and project — ${contextLabel(orgLabel, scope.project)}`}
|
||||
>
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
|
||||
{contextLabel(orgLabel, scope.project)}
|
||||
</Text>
|
||||
{org.logo ? (
|
||||
// The org's own logo IS the label — the uploaded mark takes the
|
||||
// slot the name held, height-capped to the row so any aspect fits.
|
||||
// A scoped project keeps its text beside it; the full text stays
|
||||
// in the aria-label either way. Arbitrary tenant URL/data URL, so
|
||||
// a raw <img> (next/image would need a per-tenant remote
|
||||
// allow-list) — same call BrandLogo makes.
|
||||
<XStack items="center" gap="$2" flex={1} minW={0}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={org.logo}
|
||||
alt={orgLabel}
|
||||
style={{ height: 22, width: 'auto', maxWidth: 140, objectFit: 'contain', display: 'block' }}
|
||||
/>
|
||||
{scope.project ? (
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
|
||||
/ {scope.project}
|
||||
</Text>
|
||||
) : null}
|
||||
</XStack>
|
||||
) : (
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
|
||||
{contextLabel(orgLabel, scope.project)}
|
||||
</Text>
|
||||
)}
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
|
||||
|
||||
@@ -58,18 +58,32 @@ const CUSTOM = '__custom__'
|
||||
|
||||
export function AgentBuilder({
|
||||
loaders,
|
||||
initial,
|
||||
onCreated,
|
||||
onCancel,
|
||||
submitLabel = 'Create agent',
|
||||
}: {
|
||||
loaders: AgentBuilderLoaders
|
||||
/** Called after a successful create (the host reloads its list + closes the form). */
|
||||
onCreated: () => void
|
||||
/**
|
||||
* A spec to start from — a template's preset, or what a description drafted.
|
||||
* Read ONCE, at mount: the form is the user's from that point on, so a seed can
|
||||
* never overwrite something they have already typed. A host that swaps seeds
|
||||
* (the quickstart, when a different template is picked) remounts with a `key`,
|
||||
* which states the intent — a new starting point — instead of hiding it in an
|
||||
* effect that races the user's keystrokes.
|
||||
*/
|
||||
initial?: Partial<AgentSpec>
|
||||
/**
|
||||
* Called after a successful create, with the NAME the agent was created under
|
||||
* (the handle every `/v1/agents/:ref` route is keyed by) so the host can go
|
||||
* straight to running it rather than looking it back up.
|
||||
*/
|
||||
onCreated: (name: string) => void
|
||||
/** Called when the user cancels (optional — omit for an always-open form). */
|
||||
onCancel?: () => void
|
||||
submitLabel?: string
|
||||
}) {
|
||||
const [spec, setSpec] = useState<AgentSpec>(emptySpec)
|
||||
const [spec, setSpec] = useState<AgentSpec>(() => ({ ...emptySpec(), ...initial }))
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [unavailable, setUnavailable] = useState(false)
|
||||
@@ -165,8 +179,9 @@ export function AgentBuilder({
|
||||
setError(null)
|
||||
setUnavailable(false)
|
||||
try {
|
||||
await loaders.createAgent(toCreateBody(spec))
|
||||
onCreated()
|
||||
const body = toCreateBody(spec)
|
||||
await loaders.createAgent(body)
|
||||
onCreated(body.name)
|
||||
} catch (e) {
|
||||
const c = classifyBuilderError(e)
|
||||
if (c.kind === 'unavailable') setUnavailable(true)
|
||||
@@ -202,7 +217,10 @@ export function AgentBuilder({
|
||||
loading={models.phase === 'loading'}
|
||||
error={models.phase === 'error' ? `Model catalog unavailable — type a model id. (${models.message})` : null}
|
||||
onRetry={loadModels}
|
||||
placeholder="zen-omni · gpt-4o-mini · claude-sonnet-4-5"
|
||||
// A placeholder is an example, and an example that does not exist is a lie
|
||||
// the user only discovers at the agent's first run. These are ids the live
|
||||
// catalog actually serves; the field itself offers the real list.
|
||||
placeholder="zen5 · zen5-mini · claude-sonnet-5"
|
||||
/>
|
||||
</FieldRow>
|
||||
|
||||
@@ -239,7 +257,14 @@ export function AgentBuilder({
|
||||
loading={tools.phase === 'loading'}
|
||||
error={tools.phase === 'error' ? `Tool catalog unavailable — type a tool id.` : null}
|
||||
onRetry={loadTools}
|
||||
placeholder="add a tool — e.g. web.search, code.exec"
|
||||
// No invented examples here either: the tool plane is per-org, so nobody
|
||||
// can name a tool that is certain to exist. The field offers what the org
|
||||
// has actually activated, and stays typeable for what it has not.
|
||||
placeholder={
|
||||
tools.phase === 'ready' && tools.options.length === 0
|
||||
? 'No tools activated yet — type one to use it anyway'
|
||||
: 'Search your tools'
|
||||
}
|
||||
emptyText="Press Add to include what you typed."
|
||||
/>
|
||||
<XStack gap="$2">
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AgentQuickstart — the guided way into the ONE builder: describe an agent in your
|
||||
* own words or start from a template, configure it, run it, and take the call away.
|
||||
*
|
||||
* FOUR STEPS, AND EVERY ONE IS A REAL CALL. That is the whole design constraint. A
|
||||
* ladder of steps is a promise about what happens; a step that only draws a checkmark
|
||||
* turns the promise into decoration. So:
|
||||
*
|
||||
* 1 Describe → `POST /v1/chat/completions` drafts a spec from a sentence
|
||||
* (`draftAgent`), or a template fills the form with a preset
|
||||
* 2 Configure → the SAME `AgentBuilder` every other surface uses, seeded
|
||||
* 3 Run → `POST /v1/agents/:ref/run` executes it and shows the recorded run
|
||||
* 4 Integrate → the request that just worked, as code
|
||||
*
|
||||
* Steps 1 and 3 are OPTIONAL by construction: their loaders (`draftAgent`, `runAgent`)
|
||||
* may be absent, and the step then says exactly what is missing instead of miming it.
|
||||
* Step 2 is the only one that cannot be skipped, because creating the agent is the
|
||||
* point and the builder is the one thing that does it.
|
||||
*
|
||||
* Host-agnostic like the rest of the module: everything arrives through
|
||||
* `AgentBuilderLoaders`, so chat, app and bot mount this over the same `/v1/agents`.
|
||||
*/
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Button, Card, Input, ScrollView, Spinner, Text, TextArea, XStack, YStack } from '@hanzo/gui'
|
||||
import { ArrowRight, Bot, Check, Play, Search, Terminal, X } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { AgentBuilder } from './AgentBuilder'
|
||||
import { defaultConfig, emptySpec, proposeName } from './logic'
|
||||
import { AGENT_TEMPLATES, searchTemplates, specFromTemplate, type AgentTemplate } from './templates'
|
||||
import type { AgentBuilderLoaders, AgentRunResult, AgentSpec } from './types'
|
||||
|
||||
/** The four steps, in order. The id is what the component switches on. */
|
||||
const STEPS = [
|
||||
{ id: 'describe', label: 'Describe', endpoint: 'POST /v1/agents' },
|
||||
{ id: 'configure', label: 'Configure', endpoint: '' },
|
||||
{ id: 'run', label: 'Run', endpoint: 'POST /v1/agents/:ref/run' },
|
||||
{ id: 'integrate', label: 'Integrate', endpoint: '' },
|
||||
] as const
|
||||
|
||||
type StepId = (typeof STEPS)[number]['id']
|
||||
|
||||
/**
|
||||
* The step ladder. A step reached earlier is a real link back — going back to change
|
||||
* the prompt is the most common thing a person wants here, and a ladder you cannot
|
||||
* climb down is a worse version of a heading.
|
||||
*/
|
||||
function StepLadder({ current, onGo }: { current: StepId; onGo: (s: StepId) => void }) {
|
||||
const index = STEPS.findIndex((s) => s.id === current)
|
||||
return (
|
||||
<XStack items="center" gap="$2" flexWrap="wrap" role="list" aria-label="Quickstart steps">
|
||||
{STEPS.map((s, i) => {
|
||||
const done = i < index
|
||||
const active = i === index
|
||||
return (
|
||||
<XStack key={s.id} items="center" gap="$2" role="listitem">
|
||||
{i > 0 ? <XStack width={20} height={1} bg="$borderColor" $md={{ width: 32 }} /> : null}
|
||||
<Button
|
||||
size="$2"
|
||||
chromeless
|
||||
px="$2"
|
||||
disabled={i > index}
|
||||
onPress={() => onGo(s.id)}
|
||||
opacity={i > index ? 0.45 : 1}
|
||||
aria-current={active ? 'step' : undefined}
|
||||
aria-label={`Step ${i + 1}: ${s.label}${done ? ' (done)' : ''}`}
|
||||
>
|
||||
<XStack items="center" gap="$2">
|
||||
<XStack
|
||||
width={20}
|
||||
height={20}
|
||||
rounded="$10"
|
||||
items="center"
|
||||
justify="center"
|
||||
bg={done || active ? '$color12' : 'transparent'}
|
||||
borderWidth={done || active ? 0 : 1}
|
||||
borderColor="$borderColor"
|
||||
>
|
||||
{done ? (
|
||||
<Check size={12} color="$color1" />
|
||||
) : (
|
||||
<Text fontSize="$1" fontWeight="700" color={active ? '$color1' : '$color10'}>
|
||||
{i + 1}
|
||||
</Text>
|
||||
)}
|
||||
</XStack>
|
||||
<Text fontSize="$2" fontWeight={active ? '700' : '500'} color={active ? '$color12' : '$color10'}>
|
||||
{s.label}
|
||||
</Text>
|
||||
{active && s.endpoint ? (
|
||||
<Text fontSize="$1" color="$color9" fontFamily="$mono" display="none" $md={{ display: 'flex' }}>
|
||||
{s.endpoint}
|
||||
</Text>
|
||||
) : null}
|
||||
</XStack>
|
||||
</Button>
|
||||
</XStack>
|
||||
)
|
||||
})}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** One template card in the gallery. The whole card is the control. */
|
||||
function TemplateCard({ template, onPick }: { template: AgentTemplate; onPick: () => void }) {
|
||||
return (
|
||||
<YStack
|
||||
onPress={onPick}
|
||||
cursor="pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
focusable
|
||||
onKeyDown={(e: { key?: string; preventDefault?: () => void }) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault?.()
|
||||
onPick()
|
||||
}
|
||||
}}
|
||||
gap="$1.5"
|
||||
p="$3"
|
||||
rounded="$4"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
bg="$color2"
|
||||
hoverStyle={{ bg: '$color3', borderColor: '$color8' }}
|
||||
aria-label={`Start from ${template.title}`}
|
||||
>
|
||||
<Text fontSize="$3" fontWeight="700" color="$color12">
|
||||
{template.title}
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color11">
|
||||
{template.summary}
|
||||
</Text>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** A short, quiet note — used wherever a step has to say what is missing. */
|
||||
function Note({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Text fontSize="$2" color="$color10">
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export function AgentQuickstart({
|
||||
loaders,
|
||||
onFinished,
|
||||
apiBase = 'https://api.hanzo.ai',
|
||||
}: {
|
||||
loaders: AgentBuilderLoaders
|
||||
/** Called when the user leaves the quickstart with an agent created (host reloads). */
|
||||
onFinished?: (name: string) => void
|
||||
/** The API origin the integrate snippet should show. */
|
||||
apiBase?: string
|
||||
}) {
|
||||
const [step, setStep] = useState<StepId>('describe')
|
||||
const [seed, setSeed] = useState<Partial<AgentSpec>>({})
|
||||
// Bumped whenever a NEW starting point is chosen, so the builder remounts on it
|
||||
// rather than an effect racing whatever the user has already typed.
|
||||
const [seedKey, setSeedKey] = useState(0)
|
||||
const [created, setCreated] = useState<string | null>(null)
|
||||
|
||||
// ── Step 1: describe ──────────────────────────────────────────────────────
|
||||
const [description, setDescription] = useState('')
|
||||
const [drafting, setDrafting] = useState(false)
|
||||
const [draftError, setDraftError] = useState<string | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const templates = useMemo(() => searchTemplates(query), [query])
|
||||
|
||||
const start = (next: Partial<AgentSpec>) => {
|
||||
setSeed(next)
|
||||
setSeedKey((k) => k + 1)
|
||||
setStep('configure')
|
||||
}
|
||||
|
||||
const pickTemplate = (t: AgentTemplate) => start(specFromTemplate(t, emptySpec(), defaultConfig()))
|
||||
|
||||
const describe = async () => {
|
||||
const text = description.trim()
|
||||
if (!text || drafting) return
|
||||
// Whatever happens next, the user's own words are already worth something: they
|
||||
// are the description, and they propose the handle. A draft only ever ADDS to
|
||||
// this, so a failed or absent draft still lands them in a part-filled form.
|
||||
const fallback: Partial<AgentSpec> = { description: text, name: proposeName(text) }
|
||||
if (!loaders.draftAgent) {
|
||||
start(fallback)
|
||||
return
|
||||
}
|
||||
setDrafting(true)
|
||||
setDraftError(null)
|
||||
try {
|
||||
const drafted = await loaders.draftAgent(text)
|
||||
start({ ...fallback, ...drafted })
|
||||
} catch (e) {
|
||||
// Say why, and still go — being stranded on a spinner is worse than writing
|
||||
// the prompt yourself.
|
||||
setDraftError(e instanceof Error ? e.message : 'Could not draft this one — write the prompt yourself.')
|
||||
start(fallback)
|
||||
} finally {
|
||||
setDrafting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 3: run ───────────────────────────────────────────────────────────
|
||||
const [input, setInput] = useState('')
|
||||
const [running, setRunning] = useState(false)
|
||||
const [run, setRun] = useState<AgentRunResult | null>(null)
|
||||
const [runError, setRunError] = useState<string | null>(null)
|
||||
|
||||
const doRun = async () => {
|
||||
const text = input.trim()
|
||||
if (!text || !created || !loaders.runAgent || running) return
|
||||
setRunning(true)
|
||||
setRunError(null)
|
||||
setRun(null)
|
||||
try {
|
||||
setRun(await loaders.runAgent(created, text))
|
||||
} catch (e) {
|
||||
// A failed run answers 502 with the RUN as its body, so this message is the
|
||||
// run's own reason — not a generic transport failure.
|
||||
setRunError(e instanceof Error ? e.message : 'The run did not complete.')
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const snippet = useMemo(
|
||||
() =>
|
||||
[
|
||||
`curl ${apiBase}/v1/agents/${created ?? 'your-agent'}/run \\`,
|
||||
` -H "Authorization: Bearer $HANZO_API_KEY" \\`,
|
||||
` -H "Content-Type: application/json" \\`,
|
||||
` -d '{"input":"${(input.trim() || 'your message here').replace(/'/g, "'\\''").replace(/"/g, '\\"')}"}'`,
|
||||
].join('\n'),
|
||||
[apiBase, created, input],
|
||||
)
|
||||
|
||||
return (
|
||||
<YStack gap="$4">
|
||||
<StepLadder current={step} onGo={setStep} />
|
||||
|
||||
{/* ── 1 · Describe ─────────────────────────────────────────────────── */}
|
||||
{step === 'describe' ? (
|
||||
<XStack gap="$4" items="flex-start" flexWrap="wrap">
|
||||
<YStack flex={2} minW={320} gap="$3" py="$6">
|
||||
<YStack gap="$2" items="center" py="$4">
|
||||
<Text fontSize="$8" fontWeight="800" color="$color12" style={{ textAlign: 'center' }}>
|
||||
What do you want to build?
|
||||
</Text>
|
||||
<Text fontSize="$3" color="$color11" style={{ textAlign: 'center' }}>
|
||||
Describe your agent, or start from a template.
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
<YStack
|
||||
bg="$color2"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
rounded="$7"
|
||||
px="$3"
|
||||
py="$2.5"
|
||||
gap="$2"
|
||||
data-field-box
|
||||
>
|
||||
<XStack gap="$2" items="flex-end">
|
||||
<TextArea
|
||||
flex={1}
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder="Describe your agent…"
|
||||
numberOfLines={3}
|
||||
disabled={drafting}
|
||||
borderWidth={0}
|
||||
bg="transparent"
|
||||
px="$1"
|
||||
py="$1"
|
||||
aria-label="Describe your agent"
|
||||
// Enter sends, Shift+Enter is a newline, and a key mid-IME-composition
|
||||
// is never a send — an open candidate window must not submit the turn.
|
||||
onKeyDown={(e) => {
|
||||
const ev = e as unknown as {
|
||||
key?: string
|
||||
shiftKey?: boolean
|
||||
preventDefault?: () => void
|
||||
nativeEvent?: { isComposing?: boolean }
|
||||
}
|
||||
if (ev.key === 'Enter' && !ev.shiftKey && !ev.nativeEvent?.isComposing) {
|
||||
ev.preventDefault?.()
|
||||
void describe()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="$2"
|
||||
circular
|
||||
theme="light"
|
||||
disabled={!description.trim() || drafting}
|
||||
onPress={() => void describe()}
|
||||
icon={drafting ? undefined : <ArrowRight size={16} />}
|
||||
aria-label="Draft this agent"
|
||||
>
|
||||
{drafting ? <Spinner size="small" /> : undefined}
|
||||
</Button>
|
||||
</XStack>
|
||||
</YStack>
|
||||
|
||||
{!loaders.draftAgent ? (
|
||||
<Note>
|
||||
Drafting isn’t connected here, so your words become the agent’s description and handle and you
|
||||
write the prompt in the next step.
|
||||
</Note>
|
||||
) : null}
|
||||
{draftError ? (
|
||||
<Text fontSize="$2" color="$red10">
|
||||
{draftError}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
|
||||
{/* Templates — a real gallery, searchable, each card a preset the builder
|
||||
can already express. */}
|
||||
<YStack flex={1} minW={280} gap="$2.5" p="$3" rounded="$5" borderWidth={1} borderColor="$borderColor">
|
||||
<Text fontSize="$4" fontWeight="700" color="$color12">
|
||||
Browse templates
|
||||
</Text>
|
||||
<XStack
|
||||
items="center"
|
||||
gap="$2"
|
||||
px="$2.5"
|
||||
height={34}
|
||||
rounded="$3"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
bg="$color2"
|
||||
data-field-box
|
||||
>
|
||||
<Search size={14} opacity={0.6} />
|
||||
<Input
|
||||
flex={1}
|
||||
unstyled
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder="Search templates"
|
||||
fontSize="$3"
|
||||
color="$color12"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
aria-label="Search templates"
|
||||
/>
|
||||
{query ? (
|
||||
<Button size="$1" chromeless icon={<X size={13} />} onPress={() => setQuery('')} aria-label="Clear search" />
|
||||
) : null}
|
||||
</XStack>
|
||||
<ScrollView maxH={520}>
|
||||
<YStack gap="$2">
|
||||
{templates.map((t) => (
|
||||
<TemplateCard key={t.id} template={t} onPick={() => pickTemplate(t)} />
|
||||
))}
|
||||
{templates.length === 0 ? (
|
||||
<Note>No template matches “{query.trim()}”. Describe it instead — that always works.</Note>
|
||||
) : null}
|
||||
</YStack>
|
||||
</ScrollView>
|
||||
</YStack>
|
||||
</XStack>
|
||||
) : null}
|
||||
|
||||
{/* ── 2 · Configure ────────────────────────────────────────────────── */}
|
||||
{step === 'configure' ? (
|
||||
<YStack gap="$3" maxW={720}>
|
||||
<AgentBuilder
|
||||
key={seedKey}
|
||||
loaders={loaders}
|
||||
initial={seed}
|
||||
onCancel={() => setStep('describe')}
|
||||
onCreated={(name) => {
|
||||
setCreated(name)
|
||||
setStep('run')
|
||||
onFinished?.(name)
|
||||
}}
|
||||
/>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
{/* ── 3 · Run ──────────────────────────────────────────────────────── */}
|
||||
{step === 'run' && created ? (
|
||||
<YStack gap="$3" maxW={720}>
|
||||
<XStack items="center" gap="$2">
|
||||
<Bot size={16} />
|
||||
<Text fontSize="$5" fontWeight="800" color="$color12">
|
||||
{created}
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color10">
|
||||
is live
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$2" color="$color11">
|
||||
Send it something. This runs the agent for real and bills the run to your organization.
|
||||
</Text>
|
||||
|
||||
{loaders.runAgent ? (
|
||||
<>
|
||||
<YStack
|
||||
bg="$color2"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
rounded="$5"
|
||||
px="$3"
|
||||
py="$2.5"
|
||||
data-field-box
|
||||
>
|
||||
<XStack gap="$2" items="flex-end">
|
||||
<TextArea
|
||||
flex={1}
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
placeholder="Your message to the agent…"
|
||||
numberOfLines={3}
|
||||
disabled={running}
|
||||
borderWidth={0}
|
||||
bg="transparent"
|
||||
px="$1"
|
||||
py="$1"
|
||||
aria-label="Message to the agent"
|
||||
onKeyDown={(e) => {
|
||||
const ev = e as unknown as {
|
||||
key?: string
|
||||
shiftKey?: boolean
|
||||
preventDefault?: () => void
|
||||
nativeEvent?: { isComposing?: boolean }
|
||||
}
|
||||
if (ev.key === 'Enter' && !ev.shiftKey && !ev.nativeEvent?.isComposing) {
|
||||
ev.preventDefault?.()
|
||||
void doRun()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="$2"
|
||||
theme="light"
|
||||
disabled={!input.trim() || running}
|
||||
onPress={() => void doRun()}
|
||||
icon={running ? undefined : <Play size={15} />}
|
||||
>
|
||||
{running ? <Spinner size="small" /> : 'Run'}
|
||||
</Button>
|
||||
</XStack>
|
||||
</YStack>
|
||||
|
||||
{runError ? (
|
||||
<Card gap="$1.5" p="$3" rounded="$4" bg="$color2" borderWidth={1} borderColor="$borderColor">
|
||||
<Text fontSize="$3" fontWeight="700" color="$red10">
|
||||
The run failed
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color11">
|
||||
{runError}
|
||||
</Text>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{run ? (
|
||||
<Card gap="$2" p="$3" rounded="$4" bg="$color2" borderWidth={1} borderColor="$borderColor">
|
||||
<XStack items="center" gap="$2" flexWrap="wrap">
|
||||
<Text fontSize="$2" fontWeight="700" color={run.status === 'ok' ? '$green10' : '$red10'}>
|
||||
{run.status === 'ok' ? 'ok' : run.status || 'error'}
|
||||
</Text>
|
||||
{run.model ? (
|
||||
<Text fontSize="$1" color="$color10">
|
||||
{run.model}
|
||||
</Text>
|
||||
) : null}
|
||||
{run.durationMs != null ? (
|
||||
<Text fontSize="$1" color="$color10">
|
||||
{run.durationMs} ms
|
||||
</Text>
|
||||
) : null}
|
||||
</XStack>
|
||||
<Text fontSize="$3" color="$color12">
|
||||
{run.output || run.error || 'The run recorded no output.'}
|
||||
</Text>
|
||||
</Card>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<Card gap="$1.5" p="$3" rounded="$4" bg="$color2" borderWidth={1} borderColor="$borderColor">
|
||||
<XStack items="center" gap="$2">
|
||||
<Terminal size={14} />
|
||||
<Text fontSize="$3" fontWeight="700" color="$color12">
|
||||
Running from here isn’t connected on this deployment
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$2" color="$color11">
|
||||
The agent exists and `POST /v1/agents/{created}/run` is its endpoint — the next step shows the
|
||||
call.
|
||||
</Text>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<XStack gap="$2">
|
||||
<Button flex={1} theme="light" iconAfter={<ArrowRight size={15} />} onPress={() => setStep('integrate')}>
|
||||
Integrate
|
||||
</Button>
|
||||
</XStack>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
{/* ── 4 · Integrate ────────────────────────────────────────────────── */}
|
||||
{step === 'integrate' && created ? (
|
||||
<YStack gap="$3" maxW={720}>
|
||||
<Text fontSize="$5" fontWeight="800" color="$color12">
|
||||
Call it from your code
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color11">
|
||||
The same request the Run step just made. Mint a key under API keys and set it as `HANZO_API_KEY`.
|
||||
</Text>
|
||||
<YStack p="$3" rounded="$4" bg="$color2" borderWidth={1} borderColor="$borderColor">
|
||||
<Text fontSize="$2" fontFamily="$mono" color="$color12" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{snippet}
|
||||
</Text>
|
||||
</YStack>
|
||||
<Note>
|
||||
It answers with the recorded run — its id, status, model, output and duration. A model failure comes
|
||||
back as a run with `status: "error"` and the reason, never as silence.
|
||||
</Note>
|
||||
</YStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -9,10 +9,12 @@
|
||||
* lifts cleanly into a published `@hanzo/agent-builder` package.
|
||||
*/
|
||||
export { AgentBuilder } from './AgentBuilder'
|
||||
export { AgentQuickstart } from './Quickstart'
|
||||
export type {
|
||||
AgentSpec,
|
||||
AgentConfig,
|
||||
AgentCreateBody,
|
||||
AgentRunResult,
|
||||
ReasoningEffort,
|
||||
AgentBuilderLoaders,
|
||||
BuilderOption,
|
||||
@@ -34,4 +36,10 @@ export {
|
||||
promptBodyFromRow,
|
||||
promptOptions,
|
||||
classifyBuilderError,
|
||||
draftInstruction,
|
||||
parseDraft,
|
||||
proposeName,
|
||||
toHandle,
|
||||
} from './logic'
|
||||
export { AGENT_TEMPLATES, matchTemplate, searchTemplates, templateById, specFromTemplate } from './templates'
|
||||
export type { AgentTemplate } from './templates'
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
promptBodyFromRow,
|
||||
promptOptions,
|
||||
classifyBuilderError,
|
||||
proposeName,
|
||||
toHandle,
|
||||
parseDraft,
|
||||
} from './logic'
|
||||
import type { AgentConfig, AgentSpec, BuilderOption, BuilderPrompt } from './types'
|
||||
|
||||
@@ -32,13 +35,21 @@ describe('defaultModel', () => {
|
||||
expect(defaultModel([])).toBe('')
|
||||
})
|
||||
|
||||
it('prefers the exact zen-omni default when present', () => {
|
||||
expect(defaultModel([opt('gpt-4o'), opt('zen-omni'), opt('claude')])).toBe('zen-omni')
|
||||
it('prefers the exact zen5 default when present', () => {
|
||||
expect(defaultModel([opt('gpt-4o'), opt('zen5'), opt('claude')])).toBe('zen5')
|
||||
})
|
||||
|
||||
it('falls back to the first Zen-family model (prefix or provider hint)', () => {
|
||||
expect(defaultModel([opt('gpt-4o'), opt('zen-coder')])).toBe('zen-coder')
|
||||
expect(defaultModel([opt('gpt-4o'), opt('some-model', 'Zen')])).toBe('some-model')
|
||||
it('falls back to another Zen TEXT model', () => {
|
||||
expect(defaultModel([opt('gpt-4o'), opt('zen5-coder')])).toBe('zen5-coder')
|
||||
})
|
||||
|
||||
// The defect this rule exists to prevent: a catalog arrives sorted, so a loose
|
||||
// `^zen[-.]` test selected `zen-embedding` — an embeddings SKU that cannot hold a
|
||||
// conversation — as the default model for every new agent.
|
||||
it('never defaults to a modality SKU over a text model', () => {
|
||||
const live = [opt('zen-embedding'), opt('zen-image'), opt('zen-rerank'), opt('zen-vl'), opt('zen5'), opt('zen5-mini')]
|
||||
expect(defaultModel(live)).toBe('zen5')
|
||||
expect(defaultModel(live.filter((o) => o.value !== 'zen5'))).toBe('zen5-mini')
|
||||
})
|
||||
|
||||
it('falls back to the first catalog id when no Zen model exists', () => {
|
||||
@@ -189,3 +200,69 @@ describe('classifyBuilderError', () => {
|
||||
expect(classifyBuilderError('boom')).toEqual({ kind: 'error', message: 'Could not create the agent.' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('proposeName', () => {
|
||||
it('makes a handle out of the words a person actually typed', () => {
|
||||
expect(proposeName('An agent that triages support tickets')).toBe('triages-support-tickets')
|
||||
})
|
||||
it('drops noise words and punctuation', () => {
|
||||
expect(proposeName('The agent for our billing!! questions')).toBe('billing-questions')
|
||||
})
|
||||
it('is empty when there is nothing usable', () => {
|
||||
expect(proposeName(' ')).toBe('')
|
||||
expect(proposeName('a the it')).toBe('')
|
||||
})
|
||||
it('caps the length so the handle stays a handle', () => {
|
||||
expect(proposeName('extraordinarily verbose descriptive nomenclature').length).toBeLessThanOrEqual(32)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toHandle', () => {
|
||||
it('reshapes without re-wording — a handle survives intact', () => {
|
||||
expect(toHandle('support-agent')).toBe('support-agent')
|
||||
expect(toHandle('Support Triage Bot!')).toBe('support-triage-bot')
|
||||
})
|
||||
it('collapses runs and trims the edges', () => {
|
||||
expect(toHandle(' --a // b-- ')).toBe('a-b')
|
||||
})
|
||||
it('caps the length and never ends on a hyphen', () => {
|
||||
const h = toHandle('extraordinarily verbose descriptive nomenclature here')
|
||||
expect(h.length).toBeLessThanOrEqual(32)
|
||||
expect(h.endsWith('-')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseDraft', () => {
|
||||
it('reads the three fields it asked for', () => {
|
||||
const d = parseDraft('{"name":"support-triage","description":"Triages tickets.","systemPrompt":"You triage."}')
|
||||
expect(d).toEqual({ name: 'support-triage', description: 'Triages tickets.', systemPrompt: 'You triage.' })
|
||||
})
|
||||
|
||||
// The two things models actually do to JSON.
|
||||
it('survives a code fence and surrounding prose', () => {
|
||||
const answer = 'Sure! Here you go:\n```json\n{"name":"helper","systemPrompt":"You help."}\n```\nHope that works.'
|
||||
expect(parseDraft(answer)).toEqual({ name: 'helper', systemPrompt: 'You help.' })
|
||||
})
|
||||
|
||||
it('normalizes a handle the backend would refuse', () => {
|
||||
expect(parseDraft('{"name":"Support Triage Bot!"}')?.name).toBe('support-triage-bot')
|
||||
})
|
||||
|
||||
it('accepts the snake_case and bare spellings of the prompt', () => {
|
||||
expect(parseDraft('{"system_prompt":"You help."}')?.systemPrompt).toBe('You help.')
|
||||
expect(parseDraft('{"prompt":"You help."}')?.systemPrompt).toBe('You help.')
|
||||
})
|
||||
|
||||
// A creative answer may only ever produce LESS than asked, never a field the
|
||||
// builder cannot express.
|
||||
it('drops every key it does not recognize', () => {
|
||||
const d = parseDraft('{"name":"a-b","model":"gpt-9","tools":["rm -rf"],"webhook":"http://evil"}')
|
||||
expect(d).toEqual({ name: 'a-b' })
|
||||
})
|
||||
|
||||
it('is null when there is no object, or only empty fields', () => {
|
||||
expect(parseDraft('I could not do that.')).toBeNull()
|
||||
expect(parseDraft('{ not json }')).toBeNull()
|
||||
expect(parseDraft('{"name":" ","description":""}')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,23 +23,30 @@ export function defaultConfig(): AgentConfig {
|
||||
return { temperature: 0.7, topP: 1, topK: 0, stream: true, thinking: false, useTools: true, webSearch: false }
|
||||
}
|
||||
|
||||
/** The default Zen model to preselect when the catalog offers one. */
|
||||
const ZEN_DEFAULT = 'zen-omni'
|
||||
/** The Zen text model to preselect when the catalog offers it. */
|
||||
const ZEN_DEFAULT = 'zen5'
|
||||
|
||||
/**
|
||||
* Pick a sensible default model from a live catalog: the Zen default if present,
|
||||
* else the first Zen (`hanzo`-owned / `zen-` prefixed) model, else the first
|
||||
* catalog id, else '' (nothing to default to — the field stays empty/typeable).
|
||||
* PURE. Never invents an id — only returns one the catalog actually lists.
|
||||
* else another model from the Zen TEXT family, else the first catalog id, else ''
|
||||
* (nothing to default to — the field stays empty/typeable). PURE. Never invents an
|
||||
* id — only returns one the catalog actually lists.
|
||||
*
|
||||
* The text-family test is `zen5…`, and that specificity is load-bearing. Zen's naming
|
||||
* splits cleanly: `zen5`, `zen5-mini`, `zen5-flash`, `zen5-coder`, `zen5-pro` are the
|
||||
* text models, while `zen-<noun>` names a MODALITY — zen-embedding, zen-image,
|
||||
* zen-video, zen-rerank, zen-voice, zen-vl. A looser `^zen[-.]` test matched both, and
|
||||
* since a catalog arrives sorted it selected `zen-embedding`: every agent created
|
||||
* without touching the model field was pointed at an embeddings SKU that cannot hold a
|
||||
* conversation. (It went unnoticed because the exact-match arm named `zen-omni`, which
|
||||
* the live catalog does not carry, so the fallback was always the arm that ran.)
|
||||
*/
|
||||
export function defaultModel(options: BuilderOption[]): string {
|
||||
if (options.length === 0) return ''
|
||||
const exact = options.find((o) => o.value === ZEN_DEFAULT)
|
||||
if (exact) return exact.value
|
||||
const zen = options.find(
|
||||
(o) => /^zen[-.]/i.test(o.value) || (o.hint ?? '').toLowerCase().includes('zen'),
|
||||
)
|
||||
return (zen ?? options[0]).value
|
||||
const zenText = options.find((o) => /^zen\d/i.test(o.value))
|
||||
return (zenText ?? options[0]).value
|
||||
}
|
||||
|
||||
/** True iff the spec can be submitted (a non-empty trimmed name is the only requirement). */
|
||||
@@ -150,6 +157,111 @@ export function promptOptions(prompts: BuilderPrompt[]): BuilderOption[] {
|
||||
return prompts.map((p) => ({ value: p.name, label: p.label ?? p.name, hint: p.hint }))
|
||||
}
|
||||
|
||||
// ── Drafting an agent from a sentence ───────────────────────────────────────
|
||||
//
|
||||
// The quickstart lets someone describe an agent in their own words. That is a
|
||||
// model call, so the EFFECT is an injected loader (`draftAgent`) like every other;
|
||||
// what lives here is the pure half — the instruction we send, and the parse of what
|
||||
// comes back. Both are pure so the fragile part (reading a model's JSON) is tested
|
||||
// against real malformed answers rather than trusted.
|
||||
|
||||
/**
|
||||
* The instruction that turns a description into a spec. It asks for the three
|
||||
* fields a person would otherwise type and NOTHING else — deliberately not `model`
|
||||
* or `tools`: a model id must exist in the org's live catalog and a tool must exist
|
||||
* in its tool plane, and a model asked to name one will happily invent it. Those two
|
||||
* fields stay with the pickers that know the real answers. PURE.
|
||||
*/
|
||||
export function draftInstruction(): string {
|
||||
return [
|
||||
'You turn a description of an agent into its definition.',
|
||||
'',
|
||||
'Reply with ONE JSON object and nothing else — no prose, no code fence. Keys:',
|
||||
' "name" a short lowercase handle, words joined by hyphens (e.g. support-triage)',
|
||||
' "description" one sentence on what the agent does',
|
||||
' "systemPrompt" the agent\'s own instructions, written in the second person',
|
||||
'',
|
||||
'The system prompt is the real work: state what the agent does, what it must not do,',
|
||||
'and how it should behave when it is unsure. Write it as instructions to the agent,',
|
||||
'not as a description of it.',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Stop-words that carry no meaning in a handle. */
|
||||
const NOISE = new Set(['a', 'an', 'the', 'that', 'this', 'my', 'our', 'for', 'to', 'of', 'and', 'is', 'it', 'agent'])
|
||||
|
||||
/**
|
||||
* Put any string into handle FORM: lowercase, letters and digits kept, everything
|
||||
* else a hyphen, no repeated or trailing hyphens, capped. It reshapes and never
|
||||
* re-words — `support-agent` stays `support-agent`. PURE.
|
||||
*/
|
||||
export function toHandle(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 32)
|
||||
.replace(/-+$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* A handle proposed from PROSE — the user's own sentence — so the form is never left
|
||||
* with an empty required field even when the draft call fails. Drops the words that
|
||||
* carry no meaning in a handle, keeps the first three that do, and puts the result in
|
||||
* handle form. Returns '' when the text carries nothing usable.
|
||||
*
|
||||
* Distinct from `toHandle` on purpose, and the two must not be confused: this one
|
||||
* REWORDS, which is right for a sentence and wrong for a handle. Running it over an
|
||||
* already-formed handle silently renames it — `support-agent` would come back as
|
||||
* `support`, because "agent" is noise in a sentence and load-bearing in a name. PURE.
|
||||
*/
|
||||
export function proposeName(description: string): string {
|
||||
const words = description
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, ' ')
|
||||
.split(/[\s-]+/)
|
||||
.filter((w) => w.length > 1 && !NOISE.has(w))
|
||||
return toHandle(words.slice(0, 3).join('-'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a drafted spec out of a model's answer. Tolerant of the two things models
|
||||
* actually do — wrapping the object in a ```json fence, and adding a sentence before
|
||||
* or after it — by taking the outermost braces. Every field is validated and
|
||||
* anything unrecognized is DROPPED, so a creative answer can only ever produce less
|
||||
* than asked, never a field the builder does not understand. Returns null when there
|
||||
* is no object at all. PURE.
|
||||
*/
|
||||
export function parseDraft(answer: string): Partial<AgentSpec> | null {
|
||||
const start = answer.indexOf('{')
|
||||
const end = answer.lastIndexOf('}')
|
||||
if (start < 0 || end <= start) return null
|
||||
let raw: unknown
|
||||
try {
|
||||
raw = JSON.parse(answer.slice(start, end + 1))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
||||
const r = raw as Record<string, unknown>
|
||||
const text = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
|
||||
|
||||
const out: Partial<AgentSpec> = {}
|
||||
const name = text(r.name)
|
||||
// A handle the backend would refuse is worse than none, so reshape it — but with
|
||||
// `toHandle`, which only changes the FORM. `proposeName` would also re-word it, and
|
||||
// the model was asked for a handle, not a sentence.
|
||||
if (name) {
|
||||
const handle = toHandle(name)
|
||||
if (handle) out.name = handle
|
||||
}
|
||||
const description = text(r.description)
|
||||
if (description) out.description = description
|
||||
const systemPrompt = text(r.systemPrompt) ?? text(r.system_prompt) ?? text(r.prompt)
|
||||
if (systemPrompt) out.systemPrompt = systemPrompt
|
||||
return Object.keys(out).length ? out : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a create failure. A 404 (or an explicit "unavailable" BackendState kind)
|
||||
* means the `/v1/agents` route isn't bound on this deployment — an honest "not
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { AGENT_TEMPLATES, matchTemplate, searchTemplates, specFromTemplate, templateById } from './templates'
|
||||
import { defaultConfig, emptySpec, toCreateBody } from './logic'
|
||||
|
||||
describe('AGENT_TEMPLATES', () => {
|
||||
it('has unique ids and a handle for every entry', () => {
|
||||
const ids = AGENT_TEMPLATES.map((t) => t.id)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
for (const t of AGENT_TEMPLATES) {
|
||||
expect(t.name.trim()).not.toBe('')
|
||||
expect(t.title.trim()).not.toBe('')
|
||||
expect(t.summary.trim()).not.toBe('')
|
||||
}
|
||||
})
|
||||
|
||||
it('leads with the blank one — starting from nothing is the honest default', () => {
|
||||
expect(AGENT_TEMPLATES[0].id).toBe('blank')
|
||||
expect(AGENT_TEMPLATES[0].systemPrompt).toBe('')
|
||||
})
|
||||
|
||||
// The whole point of the module doc: a template is a preset, never a promise. It
|
||||
// may only carry fields the create body can already express, so picking one can
|
||||
// never produce an agent the builder itself could not.
|
||||
it('carries nothing the create body cannot express', () => {
|
||||
const allowed = new Set(['id', 'title', 'summary', 'name', 'systemPrompt', 'config'])
|
||||
for (const t of AGENT_TEMPLATES) {
|
||||
for (const key of Object.keys(t)) expect(allowed.has(key)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
// A hardcoded tool id would name something the org may not have activated, and it
|
||||
// would fail at the agent's FIRST invocation rather than here. Tools come from the
|
||||
// live tool plane or not at all.
|
||||
it('names no tools — those come from the live tool plane', () => {
|
||||
for (const t of AGENT_TEMPLATES) expect(t).not.toHaveProperty('tools')
|
||||
})
|
||||
|
||||
it('every template produces a submittable body', () => {
|
||||
for (const t of AGENT_TEMPLATES) {
|
||||
const body = toCreateBody(specFromTemplate(t, emptySpec(), defaultConfig()))
|
||||
expect(body.name).toBe(t.name)
|
||||
expect(body.description).toBe(t.summary)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchTemplate / searchTemplates', () => {
|
||||
const t = AGENT_TEMPLATES.find((x) => x.id === 'researcher')!
|
||||
|
||||
it('an empty query matches everything', () => {
|
||||
expect(matchTemplate(t, '')).toBe(true)
|
||||
expect(matchTemplate(t, ' ')).toBe(true)
|
||||
expect(searchTemplates('')).toHaveLength(AGENT_TEMPLATES.length)
|
||||
})
|
||||
|
||||
it('matches title, summary and id, case-insensitively', () => {
|
||||
expect(matchTemplate(t, 'DEEP')).toBe(true)
|
||||
expect(matchTemplate(t, 'sources')).toBe(true)
|
||||
expect(matchTemplate(t, 'researcher')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns nothing for a query nothing carries', () => {
|
||||
expect(searchTemplates('quantum tuba')).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps gallery order', () => {
|
||||
const found = searchTemplates('a').map((x) => x.id)
|
||||
expect(found).toEqual(AGENT_TEMPLATES.filter((x) => matchTemplate(x, 'a')).map((x) => x.id))
|
||||
})
|
||||
})
|
||||
|
||||
describe('templateById', () => {
|
||||
it('finds one, and is null for an unknown id', () => {
|
||||
expect(templateById('blank')?.title).toBe('Blank agent')
|
||||
expect(templateById('nope')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('specFromTemplate', () => {
|
||||
const t = AGENT_TEMPLATES.find((x) => x.id === 'extractor')!
|
||||
|
||||
it('fills name, description and prompt from the template', () => {
|
||||
const s = specFromTemplate(t, emptySpec(), defaultConfig())
|
||||
expect(s.name).toBe(t.name)
|
||||
expect(s.description).toBe(t.summary)
|
||||
expect(s.systemPrompt).toBe(t.systemPrompt)
|
||||
})
|
||||
|
||||
// The template owns the agent's character; the MODEL is the org's own decision and
|
||||
// its tool list is the org's too, so neither is overwritten by picking one.
|
||||
it('keeps a model and tools the user already chose', () => {
|
||||
const current = { ...emptySpec(), model: 'zen5-pro', tools: ['already.picked'] }
|
||||
const s = specFromTemplate(t, current, defaultConfig())
|
||||
expect(s.model).toBe('zen5-pro')
|
||||
expect(s.tools).toEqual(['already.picked'])
|
||||
})
|
||||
|
||||
it('merges the template config over the defaults, leaving the rest alone', () => {
|
||||
const s = specFromTemplate(t, emptySpec(), defaultConfig())
|
||||
expect(s.config?.temperature).toBe(0)
|
||||
expect(s.config?.stream).toBe(defaultConfig().stream)
|
||||
})
|
||||
|
||||
it('posts no config for a template that needs none', () => {
|
||||
const blank = templateById('blank')!
|
||||
expect(specFromTemplate(blank, emptySpec(), defaultConfig()).config).toBeUndefined()
|
||||
expect(toCreateBody(specFromTemplate(blank, emptySpec(), defaultConfig()))).not.toHaveProperty('config')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Agent templates — starting points for the ONE builder, shared by every surface.
|
||||
*
|
||||
* A template is a PRESET, never a promise: every field it carries maps to something
|
||||
* `POST /v1/agents` already accepts (`name`, `description`, `systemPrompt`, and the
|
||||
* `config` knobs in `AgentConfig`). Picking one fills the builder and nothing else
|
||||
* happens — the user still sees, edits and submits the same form, so a template can
|
||||
* never create an agent the builder itself could not.
|
||||
*
|
||||
* Deliberately NO tool ids. Tools come from the live tool plane (`GET /v1/tools`),
|
||||
* which knows what an org has actually activated; a hardcoded `web.search` here would
|
||||
* name something that may not exist and would fail on the agent's first invocation.
|
||||
* What a template CAN say about tools is the truth: `useTools` and `webSearch` are
|
||||
* real switches in the agent contract, so a template that needs them turns them on
|
||||
* and the builder's live tool picker fills in the specifics.
|
||||
*
|
||||
* Pure data + pure helpers — no React, no I/O — so this lifts into
|
||||
* `@hanzo/agent-builder` with the rest of the module.
|
||||
*/
|
||||
import type { AgentConfig, AgentSpec } from './types'
|
||||
|
||||
/** A named starting point: what it is, and the spec it fills the builder with. */
|
||||
export type AgentTemplate = {
|
||||
/** Stable id — the URL/search key. */
|
||||
id: string
|
||||
/** What it is called in the gallery. */
|
||||
title: string
|
||||
/** One line on what the agent does. Shown on the card and searched. */
|
||||
summary: string
|
||||
/** The seed handle; the user renames freely before submitting. */
|
||||
name: string
|
||||
/** The system prompt this template starts from ('' for the blank one). */
|
||||
systemPrompt: string
|
||||
/** Only the knobs this template genuinely needs; the rest stay at their defaults. */
|
||||
config?: Partial<AgentConfig>
|
||||
}
|
||||
|
||||
/**
|
||||
* The gallery, in display order. `blank` leads because starting from nothing is the
|
||||
* honest default — everything after it is a real, specific job.
|
||||
*/
|
||||
export const AGENT_TEMPLATES: readonly AgentTemplate[] = [
|
||||
{
|
||||
id: 'blank',
|
||||
title: 'Blank agent',
|
||||
summary: 'A starting point with nothing assumed — name it, pick a model, write the prompt.',
|
||||
name: 'my-agent',
|
||||
systemPrompt: '',
|
||||
},
|
||||
{
|
||||
id: 'researcher',
|
||||
title: 'Deep researcher',
|
||||
summary: 'Researches a question across the web and answers with the sources it used.',
|
||||
name: 'researcher',
|
||||
systemPrompt:
|
||||
'You research questions and report what you found.\n\n' +
|
||||
'Work in steps: decide what you need to know, search for it, read the results, and only then answer. ' +
|
||||
'Prefer primary sources over summaries of them.\n\n' +
|
||||
'Every claim that came from a source carries that source. When sources disagree, say so and give both. ' +
|
||||
'When you could not find something, say that plainly instead of filling the gap — an honest gap is more ' +
|
||||
'useful than a confident guess.',
|
||||
config: { webSearch: true, thinking: true, reasoningEffort: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'extractor',
|
||||
title: 'Structured extractor',
|
||||
summary: 'Reads unstructured text and returns one typed JSON object, or says which fields were absent.',
|
||||
name: 'extractor',
|
||||
systemPrompt:
|
||||
'You turn unstructured text into one JSON object matching the schema the caller gives you.\n\n' +
|
||||
'Return the object and nothing else — no prose, no code fence, no explanation.\n\n' +
|
||||
'Copy values from the text; never infer one that is not there. A field the text does not support is null, ' +
|
||||
'and a guessed value is a defect. If the schema is ambiguous about a field, choose the reading that the ' +
|
||||
'text supports literally.',
|
||||
config: { temperature: 0, topP: 1 },
|
||||
},
|
||||
{
|
||||
id: 'support',
|
||||
title: 'Support answerer',
|
||||
summary: 'Answers product questions from your own material, and escalates the ones it cannot.',
|
||||
name: 'support',
|
||||
systemPrompt:
|
||||
'You answer product questions for customers, using the material available to you.\n\n' +
|
||||
'Answer from that material only. When it does not cover the question, say so and hand off rather than ' +
|
||||
'improvising — a wrong answer costs more than a slow one.\n\n' +
|
||||
'Lead with the answer, then the steps. Keep it short enough to act on. Never promise a behaviour, a date ' +
|
||||
'or a refund you cannot point to in the material.',
|
||||
config: { useTools: true, temperature: 0.3 },
|
||||
},
|
||||
{
|
||||
id: 'reviewer',
|
||||
title: 'Code reviewer',
|
||||
summary: 'Reads a diff and reports what will actually break, most severe first.',
|
||||
name: 'reviewer',
|
||||
systemPrompt:
|
||||
'You review code changes.\n\n' +
|
||||
'Report only defects you can name concretely: the input or state that triggers them, and the wrong output ' +
|
||||
'or crash that results. Correctness and security first, then clarity. Rank by severity.\n\n' +
|
||||
'Style preferences are not findings. Neither is a concern you cannot demonstrate — if you are unsure a ' +
|
||||
'thing is real, say you are unsure rather than listing it as a defect. Finding nothing is a valid review.',
|
||||
config: { thinking: true, reasoningEffort: 'high', temperature: 0.2 },
|
||||
},
|
||||
{
|
||||
id: 'analyst',
|
||||
title: 'Data analyst',
|
||||
summary: 'Explains a dataset — what is in it, what stands out, and what to check next.',
|
||||
name: 'analyst',
|
||||
systemPrompt:
|
||||
'You explain datasets to people who have to make a decision from them.\n\n' +
|
||||
'Start with the shape: how many rows, which columns, what period, and what is missing. Then the two or ' +
|
||||
'three things that genuinely stand out. Then what you would check next and why.\n\n' +
|
||||
'Every number you state comes from the data. Distinguish what the data shows from what you suspect, and ' +
|
||||
'name the limits — a sample too small to conclude from is the finding, not an obstacle to one.',
|
||||
config: { useTools: true, temperature: 0.2 },
|
||||
},
|
||||
{
|
||||
id: 'summarizer',
|
||||
title: 'Meeting summarizer',
|
||||
summary: 'Turns a transcript into decisions, owners and the questions still open.',
|
||||
name: 'summarizer',
|
||||
systemPrompt:
|
||||
'You turn meeting transcripts into something the people who missed it can act on.\n\n' +
|
||||
'Three sections: decisions made, actions with their owner, and questions left open. Nothing else.\n\n' +
|
||||
'Only record a decision that was actually reached — a topic discussed without resolution belongs under ' +
|
||||
'open questions. Attribute an action to a person only when the transcript names them; otherwise leave the ' +
|
||||
'owner unassigned and say so.',
|
||||
config: { temperature: 0.2 },
|
||||
},
|
||||
{
|
||||
id: 'triage',
|
||||
title: 'Incident triager',
|
||||
summary: 'Classifies an incoming report by severity and area, and drafts the first reply.',
|
||||
name: 'triage',
|
||||
systemPrompt:
|
||||
'You triage incoming incident reports.\n\n' +
|
||||
'For each one give: severity, the area it belongs to, what is affected, and a first reply to the reporter.\n\n' +
|
||||
'Severity follows blast radius, not tone — a calm report of data loss outranks an urgent one about a ' +
|
||||
'typo. When the report lacks what you need to classify it, the first reply asks for exactly that and the ' +
|
||||
'severity stays provisional. Never guess an area to avoid leaving one blank.',
|
||||
config: { temperature: 0.2, reasoningEffort: 'medium' },
|
||||
},
|
||||
]
|
||||
|
||||
/** Case-insensitive, whitespace-tolerant match over the fields a person would type. */
|
||||
export function matchTemplate(t: AgentTemplate, query: string): boolean {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return true
|
||||
return `${t.title} ${t.summary} ${t.id}`.toLowerCase().includes(q)
|
||||
}
|
||||
|
||||
/** The templates matching a query, in gallery order. */
|
||||
export function searchTemplates(query: string, templates: readonly AgentTemplate[] = AGENT_TEMPLATES): AgentTemplate[] {
|
||||
return templates.filter((t) => matchTemplate(t, query))
|
||||
}
|
||||
|
||||
/** The template with this id, or null. */
|
||||
export function templateById(id: string, templates: readonly AgentTemplate[] = AGENT_TEMPLATES): AgentTemplate | null {
|
||||
return templates.find((t) => t.id === id) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* The builder state a template starts from. Merged over the CURRENT spec so a model
|
||||
* the user already chose survives picking a template — the template owns the prompt
|
||||
* and the character of the agent, never the model, which is the org's own decision.
|
||||
*/
|
||||
export function specFromTemplate(t: AgentTemplate, current: AgentSpec, defaults: AgentConfig): AgentSpec {
|
||||
return {
|
||||
...current,
|
||||
name: t.name,
|
||||
description: t.summary,
|
||||
systemPrompt: t.systemPrompt,
|
||||
tools: current.tools,
|
||||
config: t.config ? { ...defaults, ...t.config } : undefined,
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,24 @@ export type AgentBuilderLoaders = {
|
||||
loadPromptBody?: (name: string) => Promise<string>
|
||||
/** The live tool catalog. Rejects → typeable-only tools. */
|
||||
loadTools?: () => Promise<BuilderOption[]>
|
||||
/**
|
||||
* Draft a spec from a plain-English description — the quickstart's "describe your
|
||||
* agent" box. A model call, so it is an effect like the rest; the instruction and
|
||||
* the parse of the answer are pure (`draftInstruction`, `parseDraft`) and shared.
|
||||
* Absent → the quickstart still works: the description seeds the handle and the
|
||||
* description field, and the user writes the prompt. Rejects → the same fallback,
|
||||
* with the reason shown, so a drafting failure never blocks building an agent.
|
||||
*/
|
||||
draftAgent?: (description: string) => Promise<Partial<AgentSpec>>
|
||||
/**
|
||||
* Run the agent once (`POST /v1/agents/:ref/run`) and return the RECORDED run.
|
||||
* The quickstart's third step — proving the thing that was just created actually
|
||||
* answers, which is the only step that can prove it. Absent → the step says so and
|
||||
* points at the endpoint instead of pretending. THIS SPENDS: the backend authorizes
|
||||
* the org's balance before any inference, so an unfunded org is refused rather than
|
||||
* given free compute.
|
||||
*/
|
||||
runAgent?: (name: string, input: string) => Promise<AgentRunResult>
|
||||
/**
|
||||
* Create the agent from the pruned body (`toCreateBody(spec)`). This is the ONE
|
||||
* mutation — it MUST target the unified agent backend (`POST /v1/agents`), which
|
||||
@@ -147,6 +165,21 @@ export type AgentBuilderLoaders = {
|
||||
createAgent: (body: AgentCreateBody) => Promise<unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* One recorded run, as the quickstart needs it. Deliberately the small half of the
|
||||
* backend's run view: what happened, which model did it, and what came out. A
|
||||
* `status` other than `ok` is a run that REALLY failed — the backend records the
|
||||
* failure as a run rather than hiding it — so `error` is a fact about the execution,
|
||||
* not a transport problem to guess at.
|
||||
*/
|
||||
export type AgentRunResult = {
|
||||
status: string
|
||||
model?: string
|
||||
output?: string
|
||||
error?: string
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
/** The reason a create failed, distinguished so the UI reacts correctly. */
|
||||
export type BuilderErrorKind =
|
||||
/** The `/v1/agents` route isn't bound on this deployment yet (404). */
|
||||
|
||||
@@ -14,12 +14,13 @@
|
||||
* facade (`AgentsApi.metrics`); until that route is bound they show a truthful "not
|
||||
* connected" note, never a placeholder trend. When the org has ZERO agents (or the
|
||||
* `/v1/agents` route isn't bound yet) the board is replaced by a polished
|
||||
* "create your first agent" empty state with the real New-Agent flow — never the
|
||||
* mockup's sample data.
|
||||
* "create your first agent" empty state that opens the QUICKSTART — the one way to
|
||||
* create an agent here — never the mockup's sample data.
|
||||
*
|
||||
* Style props use the @hanzo/gui v5 shorthand set (bg/p/px/py/gap/rounded/items/…).
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button, Card, Input, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { useAnalytics } from '@hanzo/event/react'
|
||||
import { EVENTS } from '@hanzo/event'
|
||||
@@ -77,7 +78,9 @@ import {
|
||||
TopAgents,
|
||||
VersionBadge,
|
||||
} from './agents/parts'
|
||||
import { AgentDetailView, NewAgentForm } from './agents/forms'
|
||||
import { AgentDetailView } from './agents/forms'
|
||||
import { agentBuilderLoaders } from './agents/loaders'
|
||||
import { AgentQuickstart } from '~/components/agent-builder'
|
||||
import { BackendStateCard, DataTable, EmptyState, PageHeader, classifyBackend, type BackendState, type Column } from '@hanzo/ui/product'
|
||||
|
||||
const PAGE_SIZE = 8
|
||||
@@ -134,6 +137,7 @@ function useAgents() {
|
||||
}
|
||||
|
||||
export function AgentsModule(props: { params: Record<string, string> }) {
|
||||
const router = useRouter()
|
||||
const detail = useDetailPane()
|
||||
const { agents, loading, error, live, activity, reload, setAgents } = useAgents()
|
||||
|
||||
@@ -226,26 +230,11 @@ export function AgentsModule(props: { params: Record<string, string> }) {
|
||||
|
||||
const analytics = useAnalytics()
|
||||
|
||||
const openNew = useCallback(
|
||||
() =>
|
||||
detail.open({
|
||||
title: 'New agent',
|
||||
subtitle: 'Define a model, prompt, and tools',
|
||||
icon: Bot,
|
||||
iconColor: agentColor,
|
||||
content: (
|
||||
<NewAgentForm
|
||||
onCancel={detail.close}
|
||||
onCreated={() => {
|
||||
analytics.capture(EVENTS.AGENT_CREATED)
|
||||
detail.close()
|
||||
void reload()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[detail, agentColor, reload, analytics],
|
||||
)
|
||||
// ONE way to create an agent, and it is the quickstart. This used to open the
|
||||
// builder in a side pane — the same component, reached by a different shape, with
|
||||
// no templates, no drafting and nowhere to run what it made. Two entrances to one
|
||||
// builder is two things to keep in step; the pane was the lesser of them.
|
||||
const openNew = useCallback(() => router.push('/agents/quickstart'), [router])
|
||||
|
||||
const header = (
|
||||
<PageHeader
|
||||
@@ -279,6 +268,36 @@ export function AgentsModule(props: { params: Record<string, string> }) {
|
||||
/>
|
||||
)
|
||||
|
||||
// Owned sub-pages: Status/Logs/Metrics render focused slices of the agents' OWN
|
||||
// runs (from /v1/agents), so they are never the empty generic o11y/ledger subpage.
|
||||
// Overview ('') shows everything. Metrics = counts + invocation trend + resource;
|
||||
// Status = health donut + agents table; Logs = the invocation activity feed.
|
||||
const routeTab = props.params?.tab ?? ''
|
||||
|
||||
// ── Quickstart ──────────────────────────────────────────────────────────────
|
||||
// Its own surface, and it answers BEFORE the list's loading/error/empty states on
|
||||
// purpose: building an agent does not depend on reading the ones that exist, and
|
||||
// the moments you most need it — no agents yet, or the registry not answering —
|
||||
// are exactly the ones those early returns would have swallowed it in.
|
||||
if (routeTab === 'quickstart') {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Build an agent"
|
||||
subtitle="Describe what you want, or start from a template. Four steps, and every one is a real call."
|
||||
/>
|
||||
<AgentQuickstart
|
||||
loaders={agentBuilderLoaders}
|
||||
apiBase={config.apiUrl}
|
||||
onFinished={() => {
|
||||
analytics.capture(EVENTS.AGENT_CREATED)
|
||||
void reload()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Initial loading ─────────────────────────────────────────────────────────
|
||||
if (loading && agents.length === 0 && !error) {
|
||||
return (
|
||||
@@ -397,11 +416,6 @@ export function AgentsModule(props: { params: Record<string, string> }) {
|
||||
const tabs: StatusTab[] = ['all', ...AGENT_STATUSES]
|
||||
const tabCount = (t: StatusTab): number => (t === 'all' ? agents.length : health[t])
|
||||
|
||||
// Owned sub-pages: Status/Logs/Metrics render focused slices of the agents' OWN
|
||||
// runs (from /v1/agents), so they are never the empty generic o11y/ledger subpage.
|
||||
// Overview ('') shows everything. Metrics = counts + invocation trend + resource;
|
||||
// Status = health donut + agents table; Logs = the invocation activity feed.
|
||||
const routeTab = props.params?.tab ?? ''
|
||||
const showMetrics = routeTab === '' || routeTab === 'metrics'
|
||||
const showStatus = routeTab === '' || routeTab === 'status'
|
||||
const showLogs = routeTab === '' || routeTab === 'logs'
|
||||
|
||||
@@ -59,8 +59,8 @@ const CHANNELS: Channel[] = [
|
||||
icon: MessageCircle,
|
||||
title: 'Join Our Discord',
|
||||
body: 'Chat live with developers and other users from our community.',
|
||||
href: 'https://discord.gg/hanzo',
|
||||
cta: 'discord.gg/hanzo',
|
||||
href: 'https://discord.gg/CJCyAsm9Vr',
|
||||
cta: 'discord.gg/CJCyAsm9Vr',
|
||||
},
|
||||
{
|
||||
icon: Linkedin,
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
import { SubNav } from '~/components/ui/SubNav'
|
||||
import { productSubpageSlug } from '~/lib/products/match'
|
||||
import { useCallback, useEffect, useState, type ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Check, ExternalLink, Lock, Users } from '@hanzogui/lucide-icons-2'
|
||||
@@ -27,6 +27,37 @@ import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { ErrorState, asApiError, type HonestCopy } from '~/components/ui/States'
|
||||
import { FieldRow, FieldSwitch, FieldText, PageHeader } from '@hanzo/ui/product'
|
||||
|
||||
/**
|
||||
* Read a chosen logo file into a compact data URL the IAM `logo` string can
|
||||
* carry. An SVG passes through verbatim (it is already small and scales);
|
||||
* a raster is downscaled to 64px tall on a canvas — twice the largest render
|
||||
* (the 28px settings preview, the 22px switcher row) — so a 4MB photo becomes
|
||||
* a few KB. The cap refuses anything that still encodes large, because a
|
||||
* megabyte logo would ride EVERY IAM org read from then on.
|
||||
*/
|
||||
const LOGO_DATA_CAP = 140 * 1024
|
||||
async function fileToLogoDataUrl(file: File): Promise<string> {
|
||||
if (file.type === 'image/svg+xml') {
|
||||
const text = await file.text()
|
||||
const url = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(text)))}`
|
||||
if (url.length > LOGO_DATA_CAP) throw new Error('That SVG is too large for a logo — simplify it or host it and paste the URL')
|
||||
return url
|
||||
}
|
||||
const bitmap = await createImageBitmap(file)
|
||||
const h = Math.min(64, bitmap.height)
|
||||
const w = Math.round((bitmap.width / bitmap.height) * h)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = w
|
||||
canvas.height = h
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('Could not read the image')
|
||||
ctx.drawImage(bitmap, 0, 0, w, h)
|
||||
const url = canvas.toDataURL('image/png')
|
||||
if (url.length > LOGO_DATA_CAP) throw new Error('That image is too complex for a logo — use a simpler mark or host it and paste the URL')
|
||||
return url
|
||||
}
|
||||
|
||||
|
||||
const IAM_COPY: HonestCopy = {
|
||||
notFound:
|
||||
'IAM (/org/iam) is not routed on this host yet. It appears automatically once the deployment proxies it to Hanzo IAM.',
|
||||
@@ -191,6 +222,7 @@ function BrandingForm({ org, canEdit, onSaved }: { org: Organization; canEdit: b
|
||||
const [displayName, setDisplayName] = useState(org.displayName ?? '')
|
||||
const [websiteUrl, setWebsiteUrl] = useState(org.websiteUrl ?? '')
|
||||
const [logo, setLogo] = useState(org.logo ?? '')
|
||||
const logoFileRef = useRef<HTMLInputElement>(null)
|
||||
const [favicon, setFavicon] = useState(org.favicon ?? '')
|
||||
const [colorPrimary, setColorPrimary] = useState(org.themeData?.colorPrimary ?? '')
|
||||
const [themeEnabled, setThemeEnabled] = useState(!!org.themeData?.isEnabled)
|
||||
@@ -252,9 +284,37 @@ function BrandingForm({ org, canEdit, onSaved }: { org: Organization; canEdit: b
|
||||
<FieldRow label="Website">
|
||||
<FieldText value={websiteUrl} onChange={(v) => onEdit(() => setWebsiteUrl(v))} disabled={ro} placeholder="https://…" />
|
||||
</FieldRow>
|
||||
<FieldRow label="Logo URL">
|
||||
<FieldRow label="Logo">
|
||||
<YStack gap="$2">
|
||||
<FieldText value={logo} onChange={(v) => onEdit(() => setLogo(v))} disabled={ro} placeholder="https://…/logo.svg" />
|
||||
<XStack gap="$2" items="center" flexWrap="wrap">
|
||||
<YStack flex={1} minW={220}>
|
||||
<FieldText value={logo} onChange={(v) => onEdit(() => setLogo(v))} disabled={ro} placeholder="https://…/logo.svg" />
|
||||
</YStack>
|
||||
{/* Upload: the file becomes a compact data URL in the SAME field,
|
||||
so one value, one save path, one preview serve it either way. */}
|
||||
<Button
|
||||
size="$2"
|
||||
disabled={ro}
|
||||
onPress={() => logoFileRef.current?.click()}
|
||||
aria-label="Upload a logo image"
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
<input
|
||||
ref={logoFileRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!f) return
|
||||
fileToLogoDataUrl(f)
|
||||
.then((url) => onEdit(() => setLogo(url)))
|
||||
.catch((err) => setSave({ phase: 'error', err: asApiError(err) }))
|
||||
}}
|
||||
/>
|
||||
</XStack>
|
||||
{logo.trim() ? (
|
||||
// Arbitrary external org logo URL — raw <img> (next/image would need a
|
||||
// per-tenant remote allow-list). Matches BrandLogo's own preview.
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Agents — the New-Agent form and the per-agent detail view, both rendered inside
|
||||
* the shared right-side `DetailPane`.
|
||||
* Agents — the per-agent detail view, rendered inside the shared right-side
|
||||
* `DetailPane`. It shows the agent's REAL facts, its REAL cost from the charged
|
||||
* commerce ledger, and a best-effort activity feed from `GET /v1/agents/:name`;
|
||||
* never fabricated telemetry.
|
||||
*
|
||||
* The New-Agent form is now a THIN adapter over the CANONICAL, shareable
|
||||
* `AgentBuilder` (`~/components/agent-builder`) — the ONE agent builder across every
|
||||
* Hanzo surface. console2 supplies its live `/v1` sources via `agentBuilderLoaders`
|
||||
* (model catalog → `/v1/models`, saved prompts → `/v1/prompts`, create →
|
||||
* `/v1/agents`); the builder owns the form, the LIVE model + prompt dropdowns, and
|
||||
* the honest states. The detail view renders the agent's REAL facts (+ best-effort
|
||||
* recent activity from `GET /v1/agents/:name`), never fabricated telemetry.
|
||||
* CREATING an agent does not live here. It used to — a thin `NewAgentForm` adapter
|
||||
* that opened the canonical `AgentBuilder` in this same pane — and that made two
|
||||
* differently-shaped entrances to one builder, only one of which could offer
|
||||
* templates, drafting, or anywhere to run what it made. The quickstart
|
||||
* (`/agents/quickstart`) is the one way now, and the board's New-Agent button goes
|
||||
* there.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
@@ -29,7 +30,6 @@ import {
|
||||
} from '~/lib/api/agents'
|
||||
import { fetchUsageRecords, agentUsageFor, type AgentUsage } from '~/lib/api/aimetrics'
|
||||
import { AgentBuilder } from '~/components/agent-builder'
|
||||
import { agentBuilderLoaders } from './loaders'
|
||||
import { StatusPill, ActivityFeed } from './parts'
|
||||
|
||||
const DASH = '—'
|
||||
@@ -48,16 +48,6 @@ function Fact({ label, value }: { label: string; value: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The New-Agent form — the canonical `AgentBuilder` wired to console2's live `/v1`
|
||||
* sources. On a successful `POST /v1/agents` it calls `onCreated` (the board
|
||||
* reloads + the pane closes); a 404/unavailable backend degrades to the builder's
|
||||
* own honest "not connected — create with the CLI" note.
|
||||
*/
|
||||
export function NewAgentForm({ onCreated, onCancel }: { onCreated: () => void; onCancel: () => void }) {
|
||||
return <AgentBuilder loaders={agentBuilderLoaders} onCreated={onCreated} onCancel={onCancel} />
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-agent detail view — REAL facts from the row, the agent's REAL cost from
|
||||
* the charged commerce ledger (grouped by `metadata.agent`, NOT a hardcoded/registry
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
*/
|
||||
import { PlaygroundApi, PromptsApi } from '~/lib/api'
|
||||
import { AgentsApi } from '~/lib/api/agents'
|
||||
import type { AgentBuilderLoaders, BuilderOption, BuilderPrompt } from '~/components/agent-builder/types'
|
||||
import { ToolsApi } from '~/lib/api/tools'
|
||||
import { defaultModel, draftInstruction, parseDraft } from '~/components/agent-builder/logic'
|
||||
import type { AgentBuilderLoaders, AgentSpec, BuilderOption, BuilderPrompt } from '~/components/agent-builder/types'
|
||||
|
||||
/** The live model catalog as builder options (id → {value,label,hint}). */
|
||||
async function loadModels(): Promise<BuilderOption[]> {
|
||||
@@ -20,6 +22,52 @@ async function loadModels(): Promise<BuilderOption[]> {
|
||||
return ids.map((id) => ({ value: id, label: id }))
|
||||
}
|
||||
|
||||
/**
|
||||
* The org's REAL callable tools (`GET /v1/tools`) as builder options. The plane spans
|
||||
* every source — connector actions, functions, zap-service routes, agents, skills and
|
||||
* the org's own MCP servers — already deduplicated by name.
|
||||
*
|
||||
* A tool that lists but is NOT activated says so in its hint, because listing it
|
||||
* silently would offer a name that resolves and then refuses at invocation time. An
|
||||
* org with nothing activated gets an empty list, which is a real answer: the field
|
||||
* stays typeable and the user is not shown a tool that does not exist.
|
||||
*/
|
||||
async function loadTools(): Promise<BuilderOption[]> {
|
||||
const tools = await ToolsApi.list()
|
||||
return tools.map((t) => ({
|
||||
value: t.name,
|
||||
label: t.name,
|
||||
hint: [t.source, t.description, t.activated ? undefined : 'not activated'].filter(Boolean).join(' · ') || undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Draft a spec from a plain-English description — one real completion through the
|
||||
* gateway, with the shared instruction and the shared parse (both pure, both tested).
|
||||
*
|
||||
* Deliberately cheap and deterministic: this is a formatting job, not a reasoning one.
|
||||
* `parseDraft` drops anything it does not recognize, so a creative answer can only
|
||||
* ever fill FEWER fields than asked — never inject one the builder cannot express.
|
||||
*/
|
||||
async function draftAgent(description: string): Promise<Partial<AgentSpec>> {
|
||||
// The model comes from the LIVE catalog through the same `defaultModel` rule the
|
||||
// builder's own picker uses — never a literal id here. A hardcoded one drifts
|
||||
// silently the moment the catalog changes, which is exactly how `zen-omni` came to
|
||||
// be named in a rule that could no longer match anything.
|
||||
const model = defaultModel(await loadModels())
|
||||
if (!model) throw new Error('No model is available to draft with — pick one and write the prompt yourself.')
|
||||
const completion = await PlaygroundApi.chat({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: draftInstruction() },
|
||||
{ role: 'user', content: description },
|
||||
],
|
||||
temperature: 0.2,
|
||||
})
|
||||
const answer = completion?.choices?.[0]?.message?.content ?? ''
|
||||
return parseDraft(answer) ?? {}
|
||||
}
|
||||
|
||||
/** The org's saved prompts as builder rows (names; bodies fetched lazily on select). */
|
||||
async function loadPrompts(): Promise<BuilderPrompt[]> {
|
||||
const prompts = await PromptsApi.list()
|
||||
@@ -72,8 +120,8 @@ export const agentBuilderLoaders: AgentBuilderLoaders = {
|
||||
loadModels,
|
||||
loadPrompts,
|
||||
loadPromptBody,
|
||||
// No live tool catalog endpoint on this deployment yet — the builder's tools
|
||||
// field stays typeable-only (honest), never a fabricated tool list. Wire
|
||||
// `loadTools` here when `/v1/agents/tools` (or the MCP tool catalog) is bound.
|
||||
loadTools,
|
||||
draftAgent,
|
||||
createAgent: (body) => AgentsApi.create(body),
|
||||
runAgent: (name, input) => AgentsApi.run(name, input),
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ function NeedHelpCard() {
|
||||
return (
|
||||
<SectionCard title="Need help?" p="$3">
|
||||
<YStack gap="$0.5">
|
||||
<ActionRow icon={<MessageSquare size={15} />} label="Join Discord" onPress={() => openExternal('https://discord.gg/hanzo')} />
|
||||
<ActionRow icon={<MessageSquare size={15} />} label="Join Discord" onPress={() => openExternal('https://discord.gg/CJCyAsm9Vr')} />
|
||||
<ActionRow icon={<LifeBuoy size={15} />} label="Contact Support" onPress={() => openExternal(`mailto:support@${apex}`)} />
|
||||
</YStack>
|
||||
</SectionCard>
|
||||
|
||||
@@ -54,7 +54,7 @@ export function ResourceRail({ config, onViewCode }: { config: ProductLandingCon
|
||||
|
||||
<LandingCard title="Need help?" p="$3">
|
||||
<YStack gap="$0.5">
|
||||
<ActionRow icon={<MessageSquare size={15} />} label="Community" sub="Join the Discord" onPress={() => openExternal('https://discord.gg/hanzo')} />
|
||||
<ActionRow icon={<MessageSquare size={15} />} label="Community" sub="Join the Discord" onPress={() => openExternal('https://discord.gg/CJCyAsm9Vr')} />
|
||||
<ActionRow icon={<LifeBuoy size={15} />} label="Contact Support" sub={`support@${apex}`} onPress={() => openExternal(supportMailto(docs))} />
|
||||
</YStack>
|
||||
</LandingCard>
|
||||
|
||||
@@ -18,6 +18,8 @@ import { XStack, YStack } from '@hanzo/gui'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { useComposer } from './useComposer'
|
||||
import { useModels, pricingOf, defaultModelId } from './useModels'
|
||||
import { modelId } from '~/lib/api/aicatalog'
|
||||
import { usePlanGate } from './usePlanGate'
|
||||
import { useChatRun } from './useChatRun'
|
||||
import { Composer } from './Composer'
|
||||
import { ResponsePanel } from './ResponsePanel'
|
||||
@@ -39,7 +41,18 @@ import { BackendStateCard } from '@hanzo/ui/product'
|
||||
export function ChatPlayground({ mode }: { mode: 'chat' | 'completions' }) {
|
||||
const composer = useComposer()
|
||||
const models = useModels()
|
||||
const gate = usePlanGate()
|
||||
const run = useChatRun()
|
||||
// What the picker OFFERS is what this org can actually run: models the
|
||||
// gateway routes right now, minus premium ones on an unpaid org (the
|
||||
// gateway 402s those; offering them would be the lying toast in picker
|
||||
// form). When the live set is empty — a gateway outage — fall back to the
|
||||
// full catalog so the picker never renders empty, and let runs answer.
|
||||
const offered = useMemo(() => {
|
||||
const live = models.entries.filter((m) => m.available)
|
||||
const pool = live.length ? live : models.entries
|
||||
return gate.paid ? pool : pool.filter((m) => !m.premium)
|
||||
}, [models.entries, gate.paid])
|
||||
const { account } = useSession()
|
||||
// Per-user history key (org-qualified username), '' until the session resolves.
|
||||
const userKey = account ? `${account.owner}/${account.name}` : ''
|
||||
@@ -63,22 +76,27 @@ export function ChatPlayground({ mode }: { mode: 'chat' | 'completions' }) {
|
||||
// present, else picking a sensible Zen-first default. Runs once.
|
||||
const seeded = useRef(false)
|
||||
useEffect(() => {
|
||||
if (seeded.current || models.phase === 'loading') return
|
||||
if (seeded.current || models.phase === 'loading' || !gate.resolved) return
|
||||
// Seed from the OFFERED pool: the default must be a model this org can
|
||||
// actually run, so a free org never boots onto a premium row.
|
||||
const offeredIds = new Set(offered.map((m) => modelId(m)))
|
||||
const pool = models.options.filter((o) => offeredIds.has(o.id))
|
||||
const seedOptions = pool.length ? pool : models.options
|
||||
const token =
|
||||
typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get(SHARE_PARAM) : null
|
||||
const shared = token ? decodeShare(token) : null
|
||||
if (shared) {
|
||||
composer.loadShare(shared)
|
||||
if (!shared.model && models.options.length) composer.setModel(defaultModelId(models.options))
|
||||
if (!shared.model && seedOptions.length) composer.setModel(defaultModelId(seedOptions))
|
||||
seeded.current = true
|
||||
} else if (models.options.length) {
|
||||
composer.setModel(defaultModelId(models.options))
|
||||
} else if (seedOptions.length) {
|
||||
composer.setModel(defaultModelId(seedOptions))
|
||||
seeded.current = true
|
||||
}
|
||||
// else: catalog error with no share link — leave unseeded so a Retry still
|
||||
// seeds the promoted default once the catalog resolves.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [models.phase])
|
||||
}, [models.phase, gate.resolved])
|
||||
|
||||
const turns: ComposerMsg[] = composer.messages.map((m) => ({ role: m.role, content: m.content }))
|
||||
|
||||
@@ -202,6 +220,12 @@ export function ChatPlayground({ mode }: { mode: 'chat' | 'completions' }) {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Response — full width, directly under the surface tabs, so the answer
|
||||
is the first thing on every screen size. The builder row follows. */}
|
||||
<YStack width="100%">
|
||||
<ResponsePanel run={run} pricing={pricing} model={composer.model} requestJson={json} />
|
||||
</YStack>
|
||||
|
||||
<XStack gap="$4" flexWrap="wrap" items="flex-start">
|
||||
{/* Builder — the composer with an attached, collapsible Model settings
|
||||
side-pane (a bottom sheet on mobile). The two outer columns each ask
|
||||
@@ -213,7 +237,7 @@ export function ChatPlayground({ mode }: { mode: 'chat' | 'completions' }) {
|
||||
<Composer
|
||||
composer={composer}
|
||||
mode={mode}
|
||||
models={models.entries}
|
||||
models={offered}
|
||||
modelsLoading={models.phase === 'loading'}
|
||||
running={run.running}
|
||||
onRun={() => void onRun()}
|
||||
@@ -250,10 +274,6 @@ export function ChatPlayground({ mode }: { mode: 'chat' | 'completions' }) {
|
||||
</XStack>
|
||||
</YStack>
|
||||
|
||||
{/* Response — a 1/3 flex column at desktop; wraps below the builder on mobile. */}
|
||||
<YStack flex={1} minW={320} gap="$3">
|
||||
<ResponsePanel run={run} pricing={pricing} model={composer.model} requestJson={json} />
|
||||
</YStack>
|
||||
</XStack>
|
||||
|
||||
{/* Mobile: the same Model settings as a dismissable bottom sheet. */}
|
||||
|
||||
@@ -12,7 +12,7 @@ export function ModelSelect({
|
||||
ids,
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder = 'model id, e.g. zen-omni',
|
||||
placeholder = 'model id, e.g. zen5-mini',
|
||||
}: {
|
||||
value: string
|
||||
ids: string[]
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { EXAMPLES } from './examples'
|
||||
|
||||
describe('EXAMPLES', () => {
|
||||
it('has unique ids and fills every field', () => {
|
||||
const ids = EXAMPLES.map((e) => e.id)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
for (const e of EXAMPLES) {
|
||||
expect(e.label.trim()).not.toBe('')
|
||||
expect(e.system.trim()).not.toBe('')
|
||||
expect(e.user.trim()).not.toBe('')
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* The defect this guards: all six examples once suggested `zen-omni` or `zen-coder`,
|
||||
* and the gateway serves neither. Applying an example falls back to the selected
|
||||
* model when the suggestion is absent, so nothing threw and nothing was logged — the
|
||||
* card just advertised a model that could never be the one that ran.
|
||||
*
|
||||
* The test is on the SHAPE, not on a list of ids, because a hardcoded catalog would
|
||||
* rot the same way the suggestions did. Zen's naming splits cleanly: `zen5…` are the
|
||||
* text models; `zen-<noun>` names a modality (embedding, image, video, rerank, voice,
|
||||
* vl, guard) and cannot hold a chat turn. A chat example must suggest a text model.
|
||||
*/
|
||||
it('suggests only Zen TEXT models — never a modality SKU or a retired id', () => {
|
||||
for (const e of EXAMPLES) {
|
||||
expect(e.model, `${e.id} suggests "${e.model}"`).toMatch(/^zen\d/)
|
||||
}
|
||||
})
|
||||
|
||||
it('never suggests the ids that were wrong', () => {
|
||||
const retired = new Set(['zen-omni', 'zen-coder'])
|
||||
for (const e of EXAMPLES) expect(retired.has(e.model)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -3,8 +3,15 @@
|
||||
*
|
||||
* Each is a clearly-labelled STARTER (not fabricated history): picking one fills
|
||||
* the System + User message and, when the suggested model is in the live catalog,
|
||||
* selects it. Pure data; no network. The `model` is a suggestion shown as a chip,
|
||||
* exactly like the mockup ("Explain quantum computing · zen-omni").
|
||||
* selects it. Pure data; no network. The `model` is a suggestion shown as a chip.
|
||||
*
|
||||
* EVERY MODEL HERE MUST BE ONE THE GATEWAY SERVES. All six once named `zen-omni` or
|
||||
* `zen-coder`, and the catalog carries neither — zen's text models are `zen5…`, while
|
||||
* `zen-<noun>` names a modality (embedding, image, video, rerank, voice, vl). Applying
|
||||
* an example falls back to the currently-selected model when the suggestion is absent
|
||||
* (`models.byId.has(...)`), so nothing broke and nothing was logged: the card simply
|
||||
* advertised a model that would never be the one that ran. `examples.test.ts` bites on
|
||||
* the shape now, so a modality SKU or a retired id cannot come back.
|
||||
*/
|
||||
export type Example = {
|
||||
id: string
|
||||
@@ -20,42 +27,42 @@ export const EXAMPLES: Example[] = [
|
||||
{
|
||||
id: 'quantum',
|
||||
label: 'Explain quantum computing',
|
||||
model: 'zen-omni',
|
||||
model: 'zen5-mini',
|
||||
system: 'You are a patient teacher. Explain clearly for a curious beginner.',
|
||||
user: 'Explain quantum computing in simple terms, with one everyday analogy.',
|
||||
},
|
||||
{
|
||||
id: 'debounce',
|
||||
label: 'Write a debounce function',
|
||||
model: 'zen-coder',
|
||||
model: 'zen5-coder',
|
||||
system: 'You are an expert TypeScript engineer. Return only the code, no prose.',
|
||||
user: 'Write a typed debounce<T> function with a cancel() method.',
|
||||
},
|
||||
{
|
||||
id: 'summarize',
|
||||
label: 'Summarize a paragraph',
|
||||
model: 'zen-omni',
|
||||
model: 'zen5-mini',
|
||||
system: 'Summarize the user text in exactly three bullet points.',
|
||||
user: 'Hanzo Cloud is a unified AI gateway exposing hundreds of models behind one OpenAI-compatible API, with built-in retrieval, billing and per-org keys, so orgs switch models without changing code.',
|
||||
},
|
||||
{
|
||||
id: 'json',
|
||||
label: 'Extract structured JSON',
|
||||
model: 'zen-omni',
|
||||
model: 'zen5-mini',
|
||||
system: 'Respond with a single minified JSON object and nothing else.',
|
||||
user: 'Extract name, role and company as JSON from: "Aoi Tanaka, the CTO at Hanzo, presented today."',
|
||||
},
|
||||
{
|
||||
id: 'reasoning',
|
||||
label: 'Step-by-step reasoning',
|
||||
model: 'zen-omni',
|
||||
model: 'zen5',
|
||||
system: 'Think step by step, then give the final answer on its own line.',
|
||||
user: 'A bat and a ball cost $1.10 in total. The bat costs $1.00 more than the ball. How much does the ball cost?',
|
||||
},
|
||||
{
|
||||
id: 'sql',
|
||||
label: 'Write a SQL query',
|
||||
model: 'zen-coder',
|
||||
model: 'zen5-coder',
|
||||
system: 'You are a senior data engineer. Return only the SQL.',
|
||||
user: 'Given users(id, created_at) and orders(id, user_id, total), write SQL for the top 5 users by total spend in 2026.',
|
||||
},
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* usePlanGate — is this org allowed the premium (frontier-priced) models?
|
||||
*
|
||||
* One question, answered from the org's own subscriptions over the same
|
||||
* scoped `/billing/*` proxy every billing module uses: any subscription in a
|
||||
* standing state (active | trialing | past_due) counts as a plan. The GATEWAY
|
||||
* is the enforcement point — it 402s a premium run regardless of what any UI
|
||||
* shows — so this hook only decides what the picker OFFERS, and it fails
|
||||
* OPEN: if billing is unreachable the playground offers everything and lets
|
||||
* the gateway answer, because hiding models on a flaky proxy would read as
|
||||
* "the catalog shrank".
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { BillingApi } from '~/lib/api/billing'
|
||||
|
||||
const STANDING = new Set(['active', 'trialing', 'past_due'])
|
||||
|
||||
export function usePlanGate(): { paid: boolean; resolved: boolean } {
|
||||
const [state, setState] = useState({ paid: true, resolved: false })
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
BillingApi.subscriptions()
|
||||
.then((subs) => {
|
||||
if (!alive) return
|
||||
const paid = subs.some((s) => STANDING.has((s.status ?? 'active').toLowerCase()))
|
||||
setState({ paid, resolved: true })
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setState({ paid: true, resolved: true })
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return state
|
||||
}
|
||||
@@ -28,18 +28,41 @@ const GAP = 12
|
||||
/** Read the current step's target rect, or null (no target / not found / hidden). */
|
||||
function readRect(target?: string): Rect | null {
|
||||
if (!target || typeof document === 'undefined') return null
|
||||
const el = document.querySelector(target)
|
||||
// The FIRST VISIBLE match, not merely the first: an anchor id can appear in a
|
||||
// hidden twin (a collapsed rail, an unmounted pane) whose zero/offscreen box
|
||||
// would strand the spotlight.
|
||||
const all = Array.from(document.querySelectorAll(target))
|
||||
const el = all.find((e) => {
|
||||
const r = e.getBoundingClientRect()
|
||||
return r.width >= 1 && r.height >= 1
|
||||
})
|
||||
if (!el) return null
|
||||
// Bring a scrolled-away target back before measuring — a tour step about an
|
||||
// element the viewport cannot see is a spotlight on nothing.
|
||||
const pre = el.getBoundingClientRect()
|
||||
const vh = window.innerHeight
|
||||
if (pre.bottom < 0 || pre.top > vh) el.scrollIntoView({ block: 'center' })
|
||||
const r = el.getBoundingClientRect()
|
||||
// A zero-size box = a hidden anchor (e.g. the desktop sidebar on a phone) → center.
|
||||
if (r.width < 1 || r.height < 1) return null
|
||||
// An anchor larger than most of the viewport is a container, not a target —
|
||||
// spotlighting it dims nothing and confuses everything. Center instead.
|
||||
if (r.width * r.height > window.innerWidth * vh * 0.7) return null
|
||||
return { top: r.top, left: r.left, width: r.width, height: r.height }
|
||||
}
|
||||
|
||||
/** Clamp a value into [min, max]. */
|
||||
const clamp = (v: number, min: number, max: number): number => Math.max(min, Math.min(v, max))
|
||||
|
||||
/** Fixed-position style for the tooltip card given the target rect + placement. */
|
||||
/** Rough card height for fit math — measured cards vary; this bounds the clamps. */
|
||||
const CARD_H = 220
|
||||
|
||||
/**
|
||||
* Fixed-position style for the tooltip card. The declared placement is a
|
||||
* PREFERENCE, not a contract: a side that has no room for the card FLIPS to
|
||||
* the opposite side, and a side pair with no room either way falls through to
|
||||
* below/above — so no step can render off-screen, whatever its author or its
|
||||
* anchor's size assumed. Every axis is then clamped into the viewport.
|
||||
*/
|
||||
function tooltipStyle(rect: Rect | null, placement: TourStep['placement']): CSSProperties {
|
||||
const vw = typeof window === 'undefined' ? 1200 : window.innerWidth
|
||||
const vh = typeof window === 'undefined' ? 800 : window.innerHeight
|
||||
@@ -47,18 +70,26 @@ function tooltipStyle(rect: Rect | null, placement: TourStep['placement']): CSSP
|
||||
return { position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', width: CARD_W, maxWidth: '92vw', zIndex: Z.popover }
|
||||
}
|
||||
const base: CSSProperties = { position: 'fixed', width: CARD_W, maxWidth: '92vw', zIndex: Z.popover }
|
||||
const fitsRight = rect.left + rect.width + GAP + CARD_W <= vw
|
||||
const fitsLeft = rect.left - GAP - CARD_W >= 0
|
||||
const fitsBelow = rect.top + rect.height + GAP + CARD_H <= vh
|
||||
let side = placement
|
||||
if (side === 'right' && !fitsRight) side = fitsLeft ? 'left' : 'bottom'
|
||||
else if (side === 'left' && !fitsLeft) side = fitsRight ? 'right' : 'bottom'
|
||||
if (side === 'bottom' && !fitsBelow && rect.top - GAP - CARD_H >= 0) side = 'top'
|
||||
const leftClamped = clamp(rect.left, GAP, Math.max(GAP, vw - CARD_W - GAP))
|
||||
switch (placement) {
|
||||
const topClamped = clamp(rect.top, GAP, Math.max(GAP, vh - CARD_H - GAP))
|
||||
switch (side) {
|
||||
case 'bottom':
|
||||
return { ...base, top: clamp(rect.top + rect.height + GAP, GAP, vh - GAP), left: leftClamped }
|
||||
return { ...base, top: clamp(rect.top + rect.height + GAP, GAP, Math.max(GAP, vh - CARD_H - GAP)), left: leftClamped }
|
||||
case 'top':
|
||||
return { ...base, bottom: clamp(vh - rect.top + GAP, GAP, vh - GAP), left: leftClamped }
|
||||
return { ...base, bottom: clamp(vh - rect.top + GAP, GAP, Math.max(GAP, vh - GAP)), left: leftClamped }
|
||||
case 'right':
|
||||
return { ...base, top: clamp(rect.top, GAP, vh - GAP), left: clamp(rect.left + rect.width + GAP, GAP, Math.max(GAP, vw - CARD_W - GAP)) }
|
||||
return { ...base, top: topClamped, left: clamp(rect.left + rect.width + GAP, GAP, Math.max(GAP, vw - CARD_W - GAP)) }
|
||||
case 'left':
|
||||
return { ...base, top: clamp(rect.top, GAP, vh - GAP), right: clamp(vw - rect.left + GAP, GAP, vw - GAP) }
|
||||
return { ...base, top: topClamped, left: clamp(rect.left - GAP - CARD_W, GAP, Math.max(GAP, vw - CARD_W - GAP)) }
|
||||
default:
|
||||
return { ...base, top: clamp(rect.top + rect.height + GAP, GAP, vh - GAP), left: leftClamped }
|
||||
return { ...base, top: clamp(rect.top + rect.height + GAP, GAP, Math.max(GAP, vh - CARD_H - GAP)), left: leftClamped }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* SubNav — the ONE level-2 nav for a product.
|
||||
*
|
||||
* Clicking into a product reveals ITS options rather than replacing the screen.
|
||||
* That second level lives in the SIDEBAR (`DrillNav`) — the house pattern, the one
|
||||
* the whole console already drills with. This strip is the SAME nav, rendered from
|
||||
* That second level lives in the SIDEBAR (`SubRows`), expanded beneath the product's
|
||||
* own row — the house pattern. This strip is the SAME nav, rendered from
|
||||
* the SAME source (`productSubpages` over the registry), for the viewports where
|
||||
* the sidebar is not on screen: below `lg` the sidebar is a drawer, so the strip
|
||||
* carries level 2 in the content column and hides itself at `lg+` where the rail
|
||||
@@ -75,7 +75,7 @@ export function SubNav({
|
||||
const to = href ?? ((slug: string) => subpageHref(id, slug))
|
||||
|
||||
return (
|
||||
// Hidden at lg+ — the sidebar's DrillNav is the level-2 nav there. Purely a
|
||||
// Hidden at lg+ — the sidebar's SubRows is the level-2 nav there. Purely a
|
||||
// CSS media style prop (not a JS media branch), so SSR and first paint match.
|
||||
<XStack
|
||||
gap="$1.5"
|
||||
|
||||
@@ -50,6 +50,15 @@ export type ConsoleConfig = {
|
||||
brandName: string
|
||||
/** Unified cloud backend base URL (hanzoai/cloud /v1) — shared across brands. */
|
||||
cloudUrl: string
|
||||
/**
|
||||
* The PUBLIC gated API host — what a customer's own code calls, and the only base
|
||||
* that belongs in a copyable snippet. Distinct from `cloudUrl`, which is SAME-ORIGIN
|
||||
* in the browser: printing that would hand someone `https://console.<brand>/v1/…`,
|
||||
* a URL that works for the SPA's proxied session and not for their API key. Shared
|
||||
* across brands, because the cloud backend is one multi-tenant `/v1` scoped by the
|
||||
* brand JWT's org — there is no per-brand API host to resolve.
|
||||
*/
|
||||
apiUrl: string
|
||||
/** PaaS base URL (DOKS cluster control plane) — shared. */
|
||||
platformUrl: string
|
||||
/**
|
||||
@@ -469,6 +478,7 @@ export function resolveConfig(host: string = currentHost()): ConsoleConfig {
|
||||
brand,
|
||||
brandName: b.brandName,
|
||||
cloudUrl: cloudUrl(),
|
||||
apiUrl: trimSlash(process.env.NEXT_PUBLIC_API_URL ?? 'https://api.hanzo.ai'),
|
||||
iamUrl: trimSlash(process.env.NEXT_PUBLIC_IAM_URL ?? b.iamUrl),
|
||||
iamOrgName: process.env.NEXT_PUBLIC_IAM_ORG_NAME ?? org,
|
||||
iamAppName: process.env.NEXT_PUBLIC_IAM_APP_NAME ?? app,
|
||||
|
||||
+148
-136
@@ -1,19 +1,27 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Dashboard shell — a TWO-LEVEL sidebar (product list ⇄ drill into a product) + top
|
||||
* bar + content, responsive across phone / tablet / laptop / desktop.
|
||||
* Dashboard shell — a TWO-LEVEL sidebar (products, each expanding its own sub-pages
|
||||
* in place) + top bar + content, responsive across phone / tablet / laptop / desktop.
|
||||
*
|
||||
* Level 1 (the product list) renders from the catalog: fixed Overview/Docs, a
|
||||
* Pinned section the user curates, then every product grouped by category. Each
|
||||
* CATEGORY is an INDEPENDENTLY collapsible section that renders EXPANDED by default
|
||||
* (nothing auto-collapses); the header is flush-left with the top-level items and
|
||||
* carries an OPTIONAL collapse chevron whose state persists per-user. Clicking a
|
||||
* PRODUCT that has sub-pages DRILLS the sidebar INTO that product's sub-nav
|
||||
* (Overview + specifics + the uniform base set: Settings · Status · Logs · Metrics)
|
||||
* with a clear BACK affordance to the full list — Level 2. A product with only an
|
||||
* Overview navigates directly (no drill). Sub-pages with no backend yet are dimmed
|
||||
* and open an honest placeholder (never a dead link).
|
||||
* carries an OPTIONAL collapse chevron whose state persists per-user.
|
||||
*
|
||||
* Level 2 — a product's sub-pages (Overview + specifics + the uniform base set:
|
||||
* Settings · Status · Logs · Metrics) — expands BENEATH that product's own row, so
|
||||
* its options appear without the rest of the catalog going away. The label
|
||||
* navigates; the chevron beside it only expands or collapses, and that choice
|
||||
* persists per-user (the product you are IN is open unless you closed it). Sub-pages
|
||||
* with no backend yet are dimmed and open an honest placeholder, never a dead link.
|
||||
*
|
||||
* This replaced a DRILL: clicking a product used to swap the whole rail for that
|
||||
* product's sub-nav, behind a "Back to all products" button. The options were the
|
||||
* same either way — what the drill took away was every OTHER product, which is
|
||||
* precisely what someone needs when the reason they opened the rail was to go
|
||||
* somewhere else.
|
||||
*
|
||||
* Level 2 is DECLARED once, in the registry (`subpages` + `indexLabel`), and read
|
||||
* here and by `SubNav` (the same nav, for the viewports where this sidebar is a
|
||||
@@ -42,11 +50,10 @@
|
||||
* NOT a JS media branch, so SSR and first paint match. The nav body (`SidebarNav`) is
|
||||
* shared by the sidebar, the flyout, and the drawer (DRY) — one definition, many mounts.
|
||||
*/
|
||||
import { useEffect, useMemo, useState, type ComponentType, type ReactNode } from 'react'
|
||||
import { useMemo, useState, type ComponentType, type ReactNode } from 'react'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { Button, Input, ScrollView, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import {
|
||||
ArrowLeft,
|
||||
BarChart3,
|
||||
Bell,
|
||||
BookOpen,
|
||||
@@ -61,7 +68,6 @@ import {
|
||||
Lock,
|
||||
Menu,
|
||||
PanelLeft,
|
||||
Plus,
|
||||
Repeat,
|
||||
ScrollText,
|
||||
Search,
|
||||
@@ -87,10 +93,19 @@ import { ConsoleFooter } from '~/components/ConsoleFooter'
|
||||
import { openProduct } from '~/lib/products/open'
|
||||
import { entryMatches } from '~/lib/products/search'
|
||||
import { usePins, useProductColors } from '~/lib/products/pins'
|
||||
import { useAppsBeta } from '~/lib/products/beta'
|
||||
import { orderEntries } from '~/lib/products/order'
|
||||
import { categoryIsOpen, toggleCategory, NAV_OPEN_PREF, EMPTY_OPEN, type CategoryOpen } from '~/lib/products/nav-accordion'
|
||||
import {
|
||||
categoryIsOpen,
|
||||
toggleCategory,
|
||||
productIsOpen,
|
||||
toggleProduct,
|
||||
NAV_OPEN_PREF,
|
||||
NAV_PRODUCT_OPEN_PREF,
|
||||
EMPTY_OPEN,
|
||||
type CategoryOpen,
|
||||
} from '~/lib/products/nav-accordion'
|
||||
import { usePreferences } from '~/lib/products/preferences'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { useEntitlements } from '~/lib/entitlements-context'
|
||||
import { AddProductPanel } from '~/components/AddProductPanel'
|
||||
@@ -195,14 +210,18 @@ function FixedRow({
|
||||
)
|
||||
}
|
||||
|
||||
/** A level-1 catalog row — colored product icon, opens the product; trailing is a
|
||||
* pin star (catalog rows) or a color/customize dot (pinned rows). */
|
||||
/** A level-1 catalog row — colored product icon, opens the product; trailing is an
|
||||
* expansion chevron (products with sub-pages) then a pin star (catalog rows) or a
|
||||
* color/customize dot (pinned rows). */
|
||||
function NavRow({
|
||||
entry,
|
||||
active,
|
||||
color,
|
||||
collapsed,
|
||||
pinned,
|
||||
expandable,
|
||||
expanded,
|
||||
onExpand,
|
||||
onOpen,
|
||||
onToggle,
|
||||
onCustomize,
|
||||
@@ -212,6 +231,10 @@ function NavRow({
|
||||
color: string
|
||||
collapsed: boolean
|
||||
pinned?: boolean
|
||||
/** True when the product has sub-pages to expand beneath it. */
|
||||
expandable?: boolean
|
||||
expanded?: boolean
|
||||
onExpand?: () => void
|
||||
onOpen: () => void
|
||||
onToggle?: () => void
|
||||
onCustomize?: () => void
|
||||
@@ -249,6 +272,27 @@ function NavRow({
|
||||
>
|
||||
{entry.label}
|
||||
</Button>
|
||||
{/* Expansion is its OWN control, separate from the row: the label navigates,
|
||||
the chevron only opens or closes. One target that did both would make
|
||||
"show me what's in here" and "take me there" the same gesture. */}
|
||||
{expandable && onExpand ? (
|
||||
<Button
|
||||
size="$2"
|
||||
chromeless
|
||||
onPress={onExpand}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={`nav-sub-${entry.id}`}
|
||||
aria-label={`${expanded ? 'Collapse' : 'Expand'} ${entry.label}`}
|
||||
icon={
|
||||
<span
|
||||
className="hz-chevron"
|
||||
style={{ display: 'inline-flex', transform: expanded ? 'rotate(90deg)' : undefined }}
|
||||
>
|
||||
<ChevronRight size={14} color="$color9" />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{onCustomize ? (
|
||||
<ColorDot color={color} onPress={onCustomize} label={`Customize ${entry.label}`} />
|
||||
) : onToggle ? (
|
||||
@@ -266,58 +310,32 @@ function NavRow({
|
||||
}
|
||||
|
||||
/**
|
||||
* Level 2 — the drilled product's sub-nav. Reached by clicking a product with
|
||||
* sub-pages; a BACK affordance returns to the full product list (Level 1). The
|
||||
* header shows the product (colored icon + name) under a category breadcrumb; the
|
||||
* body is `productSubpages(entry)` — Overview + specifics + the uniform base set —
|
||||
* with an unwired sub-page dimmed but honest (opens a placeholder, never a dead link).
|
||||
* Level 2 — a product's sub-pages, expanded IN PLACE beneath its own row. The list
|
||||
* is `productSubpages(entry)` (Overview + specifics + the uniform base set), with an
|
||||
* unwired sub-page dimmed but honest: it opens a placeholder, never a dead link.
|
||||
*
|
||||
* Indented under the product and hung on a hairline, so the nesting is legible
|
||||
* without a second heading — the product's own row above IS the heading. Collapsed,
|
||||
* the rows are `inert`, so hidden options leave the tab order.
|
||||
*/
|
||||
function DrillNav({
|
||||
function SubRows({
|
||||
entry,
|
||||
subs,
|
||||
pathname,
|
||||
color,
|
||||
onBack,
|
||||
open,
|
||||
onGo,
|
||||
}: {
|
||||
entry: CatalogEntry
|
||||
subs: ProductSubpage[]
|
||||
pathname: string
|
||||
color: string
|
||||
onBack: () => void
|
||||
open: boolean
|
||||
onGo: (path: string) => void
|
||||
}) {
|
||||
const activeSlug = activeSubpage(pathname, entry.id)
|
||||
const Icon = entry.icon
|
||||
return (
|
||||
<>
|
||||
{/* Back to the full product list, with the category as a quiet breadcrumb. */}
|
||||
<Button
|
||||
chromeless
|
||||
size="$2"
|
||||
height={34}
|
||||
px="$2"
|
||||
justify="flex-start"
|
||||
icon={<ArrowLeft size={17} />}
|
||||
onPress={onBack}
|
||||
hoverStyle={{ bg: '$color3' }}
|
||||
aria-label="Back to all products"
|
||||
>
|
||||
<Text fontSize="$1" color="$color10" fontWeight="500">
|
||||
{entry.category}
|
||||
</Text>
|
||||
</Button>
|
||||
|
||||
{/* The product header. */}
|
||||
<XStack items="center" gap="$2.5" px="$2" py="$1.5" mb="$1">
|
||||
<ProductIcon icon={Icon} color={color} size={22} />
|
||||
<Text flex={1} fontSize="$5" fontWeight="800" color="$color12" numberOfLines={1}>
|
||||
{entry.label}
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
<ScrollView flex={1} minH={0}>
|
||||
<YStack gap="$0.5">
|
||||
<div className="hz-acc" data-open={open ? 'true' : 'false'} id={`nav-sub-${entry.id}`} inert={!open}>
|
||||
<div className="hz-acc-inner">
|
||||
<YStack gap="$0.5" ml="$4" pl="$2" pt="$0.5" borderLeftWidth={1} borderColor="$borderColor">
|
||||
{subs.map((sp) => {
|
||||
const wired = subpageWired(entry.id, sp.slug)
|
||||
const active = sp.slug === activeSlug
|
||||
@@ -328,9 +346,9 @@ function DrillNav({
|
||||
onPress={() => onGo(sp.slug ? `/${entry.id}/${sp.slug}` : `/${entry.id}`)}
|
||||
bg={active ? '$color4' : 'transparent'}
|
||||
justify="flex-start"
|
||||
icon={<SubIcon size={17} />}
|
||||
icon={<SubIcon size={15} />}
|
||||
iconAfter={!wired ? <Circle size={7} opacity={0.5} /> : undefined}
|
||||
size="$3"
|
||||
size="$2"
|
||||
opacity={wired ? 1 : 0.6}
|
||||
aria-label={wired ? sp.label : `${sp.label} (not available yet)`}
|
||||
>
|
||||
@@ -339,8 +357,8 @@ function DrillNav({
|
||||
)
|
||||
})}
|
||||
</YStack>
|
||||
</ScrollView>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -455,6 +473,7 @@ function SidebarNav({
|
||||
const { colorOf } = useProductColors()
|
||||
const detail = useDetailPane()
|
||||
const showAdmin = useIsSuperAdmin()
|
||||
const showBeta = useAppsBeta(showAdmin)
|
||||
// Entitlement scope: currently ungated in prod (the endpoint 404s → `enabled` is
|
||||
// null → the full catalog shows), matching "every product is always available".
|
||||
const { enabled } = useEntitlements()
|
||||
@@ -467,31 +486,23 @@ function SidebarNav({
|
||||
const navOpen = prefs.get<CategoryOpen>(NAV_OPEN_PREF, EMPTY_OPEN)
|
||||
const toggleSection = (category: string) => prefs.set(NAV_OPEN_PREF, toggleCategory(navOpen, category))
|
||||
|
||||
// ── Drill-in state (Level 1 ⇄ Level 2) ────────────────────────────────────
|
||||
// ── Level 2, in place ─────────────────────────────────────────────────────
|
||||
// A product's sub-pages expand beneath its own row; nothing replaces the list.
|
||||
const activeId = activeModuleId(pathname)
|
||||
const activeEntry = activeId ? findEntry(activeId) ?? null : null
|
||||
const activeSubs = useMemo(
|
||||
() => (activeEntry && activeEntry.kind === 'module' ? productSubpages(activeEntry, showAdmin) : []),
|
||||
[activeEntry, showAdmin],
|
||||
)
|
||||
// `manualList` = the user hit BACK — force the Level-1 list even though the active
|
||||
// route is a drillable product. Entering a DIFFERENT product resets it (auto-drill).
|
||||
const [manualList, setManualList] = useState(false)
|
||||
useEffect(() => {
|
||||
setManualList(false)
|
||||
}, [activeId])
|
||||
const canDrill = Boolean(activeEntry) && activeSubs.length > 1
|
||||
const drilled = canDrill && !manualList && !collapsed
|
||||
const isActive = (id: string) => pathname === `/${id}` || pathname.startsWith(`/${id}/`)
|
||||
const productOpen = prefs.get<CategoryOpen>(NAV_PRODUCT_OPEN_PREF, EMPTY_OPEN)
|
||||
const toggleExpand = (id: string) =>
|
||||
prefs.set(NAV_PRODUCT_OPEN_PREF, toggleProduct(productOpen, id, { active: id === activeId }))
|
||||
|
||||
// Navigate to a LEAF (a sub-page or a no-sub-page product) — closes the drawer.
|
||||
const go = (path: string) => {
|
||||
router.push(path)
|
||||
onNavigate()
|
||||
}
|
||||
// Open a product from the list: DRILL if it has sub-pages (keep the drawer open so
|
||||
// the sub-nav shows), else navigate directly (leaf → close the drawer). An external
|
||||
// launch tile opens its deployed app in a new tab.
|
||||
// Open a product from the list. One with sub-pages keeps the drawer open, because
|
||||
// becoming active expands it in place and its options are the next thing to read;
|
||||
// a leaf navigates and closes. An external launch tile opens its deployed app in a
|
||||
// new tab.
|
||||
const open = (entry: CatalogEntry) => {
|
||||
if (entry.kind === 'external') {
|
||||
openProduct(entry, go)
|
||||
@@ -501,12 +512,48 @@ function SidebarNav({
|
||||
setFilter('')
|
||||
const subs = productSubpages(entry, showAdmin)
|
||||
if (subs.length > 1) {
|
||||
setManualList(false)
|
||||
router.push(`/${entry.id}`) // DRILL — keep the drawer open for the sub-nav
|
||||
router.push(`/${entry.id}`) // its sub-pages open beneath it
|
||||
} else {
|
||||
go(`/${entry.id}`) // leaf — navigate + close
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ONE product row — the row itself plus, for a product that has them, its sub-pages
|
||||
* expanded beneath. Both the Pinned group and the category groups render through
|
||||
* this, so a product looks and behaves identically wherever it appears.
|
||||
*/
|
||||
const productRow = (entry: CatalogEntry, opts: { pinned?: boolean } = {}) => {
|
||||
const subs = productSubpages(entry, showAdmin)
|
||||
// A pinned product appears TWICE — once under Pinned, once in its category — and
|
||||
// only ONE of those may carry the sub-pages. Two copies of the same list is two
|
||||
// navs painting at once, which is the very thing this rail exists to avoid, and
|
||||
// it doubles the rail's height for no information. The PINNED copy owns it: the
|
||||
// user put it up there, and it is the one they read first.
|
||||
const owns = opts.pinned || !isPinned(entry.id)
|
||||
const expandable = owns && entry.kind === 'module' && subs.length > 1
|
||||
const expanded = expandable && productIsOpen(productOpen, entry.id, { filtering, active: entry.id === activeId })
|
||||
return (
|
||||
<YStack key={`${opts.pinned ? 'pin' : 'cat'}-${entry.id}`} gap="$0.5">
|
||||
<NavRow
|
||||
entry={entry}
|
||||
active={isActive(entry.id)}
|
||||
color={colorOf(entry.id)}
|
||||
collapsed={false}
|
||||
pinned={opts.pinned ?? isPinned(entry.id)}
|
||||
expandable={expandable}
|
||||
expanded={expanded}
|
||||
onExpand={expandable ? () => toggleExpand(entry.id) : undefined}
|
||||
onOpen={() => open(entry)}
|
||||
onToggle={opts.pinned ? undefined : () => toggle(entry.id)}
|
||||
onCustomize={opts.pinned ? () => customize(entry) : undefined}
|
||||
/>
|
||||
{expandable ? (
|
||||
<SubRows entry={entry} subs={subs} pathname={pathname} open={expanded} onGo={go} />
|
||||
) : null}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
const openDocs = () => {
|
||||
if (typeof window !== 'undefined') window.open(config.docsUrl, '_blank', 'noopener')
|
||||
onNavigate()
|
||||
@@ -544,11 +591,11 @@ function SidebarNav({
|
||||
...g,
|
||||
entries: g.entries.filter((e) => {
|
||||
const found = findEntry(e.id)
|
||||
return Boolean(found) && (showAdmin || !found!.admin)
|
||||
return Boolean(found) && (showAdmin || !found!.admin) && (showBeta || !found!.beta)
|
||||
}),
|
||||
}))
|
||||
.filter((g) => g.entries.length > 0),
|
||||
[view, showAdmin],
|
||||
[view, showAdmin, showBeta],
|
||||
)
|
||||
|
||||
// Within-scope ordering is CONTINUOUS ALPHABETICAL with the SELECTED product pinned
|
||||
@@ -556,10 +603,14 @@ function SidebarNav({
|
||||
// canonical order; only the items inside each are alphabetized + selected-first.
|
||||
const groups = useMemo(
|
||||
() =>
|
||||
visibleCatalogByCategory(showAdmin, enabled)
|
||||
// Search is DISCOVERY: while a query is typed the entitlement scope opens
|
||||
// to the whole catalog — the point of searching is finding what you do
|
||||
// not have yet — while the admin and beta gates keep holding. The resting
|
||||
// rail stays scoped to the org's enabled set.
|
||||
visibleCatalogByCategory(showAdmin, filtering ? null : enabled, showBeta)
|
||||
.map((g) => ({ category: g.category, entries: orderEntries(g.entries.filter((e) => entryMatches(e, q)), activeId) }))
|
||||
.filter((g) => g.entries.length > 0),
|
||||
[q, showAdmin, enabled, activeId],
|
||||
[q, filtering, showAdmin, showBeta, enabled, activeId],
|
||||
)
|
||||
|
||||
// ── Product-shell face — the nav IS the root module's sub-pages ────────────
|
||||
@@ -594,9 +645,9 @@ function SidebarNav({
|
||||
{shell.wordmark}
|
||||
</Text>
|
||||
</XStack>
|
||||
) : (
|
||||
<SidebarBrand collapsed={collapsed} onNavigate={onNavigate} />
|
||||
)}
|
||||
) : collapsed ? (
|
||||
<SidebarBrand collapsed onNavigate={onNavigate} />
|
||||
) : null}
|
||||
<ScrollView flex={1}>
|
||||
<YStack gap="$1">
|
||||
{subs.map((sp) => {
|
||||
@@ -633,7 +684,7 @@ function SidebarNav({
|
||||
const seen = new Set<string>()
|
||||
for (const id of [...pinnedIds, ...(activeId ? [activeId] : [])]) {
|
||||
const e = findEntry(id)
|
||||
if (e && !seen.has(id) && (showAdmin || !e.admin)) {
|
||||
if (e && !seen.has(id) && (showAdmin || !e.admin) && (showBeta || !e.beta)) {
|
||||
seen.add(id)
|
||||
railIds.push(id)
|
||||
}
|
||||
@@ -674,27 +725,9 @@ function SidebarNav({
|
||||
)
|
||||
}
|
||||
|
||||
// ── Level 2 — drilled into a product's sub-nav, with a BACK affordance ──
|
||||
if (drilled && activeEntry) {
|
||||
return (
|
||||
<>
|
||||
<SidebarBrand collapsed={false} onNavigate={onNavigate} />
|
||||
<DrillNav
|
||||
entry={activeEntry}
|
||||
subs={activeSubs}
|
||||
pathname={pathname}
|
||||
color={colorOf(activeEntry.id)}
|
||||
onBack={() => setManualList(true)}
|
||||
onGo={go}
|
||||
/>
|
||||
<SidebarAccount collapsed={false} />
|
||||
<SidebarWallet collapsed={false} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Level 1 — the full product list: brand; filter; Overview/Docs; Pinned; every
|
||||
// category (EXPANDED by default, collapsible); All-products; identity + wallet. ──
|
||||
// ── The product list: brand; filter; Overview/Docs; Pinned; every category
|
||||
// (EXPANDED by default, collapsible), each product expanding its own sub-pages
|
||||
// in place; All-products; identity + wallet. ──
|
||||
return (
|
||||
// The product rail is a NAVIGATION LANDMARK. It had no role at all, so a
|
||||
// screen-reader user had no way to jump to the product list and no way to
|
||||
@@ -702,12 +735,13 @@ function SidebarNav({
|
||||
// effect (the children keep their parent's flex context), and the explicit
|
||||
// role survives regardless of how a given AT treats display:contents.
|
||||
<nav role="navigation" aria-label="Products" style={{ display: 'contents' }}>
|
||||
<SidebarBrand collapsed={false} onNavigate={onNavigate} />
|
||||
|
||||
{/* WHERE you are — organization and project in ONE control, directly under
|
||||
the tenant's own mark. The account at the foot answers WHO you are; the
|
||||
network chip in the top-right is a global MODE. Three questions, three
|
||||
controls, each in one place. */}
|
||||
{/* WHERE you are — organization and project in ONE control, and the FIRST
|
||||
thing in the rail: the org's own logo (when IAM carries one) or its
|
||||
name IS the mark, so a separate brand row above it said the same thing
|
||||
twice. The account at the foot answers WHO you are; the network chip
|
||||
in the top-right is a global MODE. Three questions, three controls,
|
||||
each in one place. The collapsed icon rail keeps its mark — there is
|
||||
no switcher to carry the identity there. */}
|
||||
<ContextSwitcher />
|
||||
|
||||
{/* Product filter — narrows the whole list; a match from any category jumps
|
||||
@@ -771,18 +805,7 @@ function SidebarNav({
|
||||
{group.entries.map((e) => {
|
||||
const entry = findEntry(e.id)
|
||||
if (!entry) return null
|
||||
return (
|
||||
<NavRow
|
||||
key={`pin-${e.id}`}
|
||||
entry={entry}
|
||||
active={isActive(e.id)}
|
||||
color={colorOf(e.id)}
|
||||
collapsed={false}
|
||||
pinned
|
||||
onOpen={() => open(entry)}
|
||||
onCustomize={() => customize(entry)}
|
||||
/>
|
||||
)
|
||||
return productRow(entry, { pinned: true })
|
||||
})}
|
||||
</YStack>
|
||||
))}
|
||||
@@ -797,18 +820,7 @@ function SidebarNav({
|
||||
open={categoryIsOpen(navOpen, group.category, { filtering })}
|
||||
onToggle={() => toggleSection(group.category)}
|
||||
>
|
||||
{group.entries.map((entry) => (
|
||||
<NavRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
active={isActive(entry.id)}
|
||||
color={colorOf(entry.id)}
|
||||
collapsed={false}
|
||||
pinned={isPinned(entry.id)}
|
||||
onOpen={() => open(entry)}
|
||||
onToggle={() => toggle(entry.id)}
|
||||
/>
|
||||
))}
|
||||
{group.entries.map((entry) => productRow(entry))}
|
||||
</CategorySection>
|
||||
))}
|
||||
|
||||
|
||||
@@ -531,6 +531,40 @@ export type NewAgentBody = {
|
||||
config?: Partial<AgentConfig>
|
||||
}
|
||||
|
||||
/**
|
||||
* One recorded run of an agent — what `POST /v1/agents/:ref/run` answers with.
|
||||
*
|
||||
* Every run this carries reflects an execution that ACTUALLY happened: the backend
|
||||
* records a model failure as a run with `status: "error"` and its message, and
|
||||
* answers 502 with that same run as the body. So a caller reads the run either way,
|
||||
* and a failure is a fact about the run rather than a transport error to guess at.
|
||||
*/
|
||||
export type AgentRun = {
|
||||
id: string
|
||||
/** `ok` when the completion came back, `error` when it did not. */
|
||||
status: string
|
||||
/** The model actually used — a failover run reports the one it fell over to. */
|
||||
model: string
|
||||
/** The completion, when there was one. */
|
||||
output?: string
|
||||
/** The failure, when there was one. */
|
||||
error?: string
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
/** Normalize a run payload defensively — a renamed field degrades, never throws. */
|
||||
export function normalizeRun(payload: unknown): AgentRun {
|
||||
const r = asRecord(payload)
|
||||
return {
|
||||
id: str(r.id) ?? '',
|
||||
status: str(r.status) ?? '',
|
||||
model: str(r.model) ?? '',
|
||||
output: str(r.output),
|
||||
error: str(r.error),
|
||||
durationMs: num(r.durationMs),
|
||||
}
|
||||
}
|
||||
|
||||
export const AgentsApi = {
|
||||
/** The agent registry (`GET /v1/agents`). Honest-empty/error until bound. */
|
||||
list: (): Promise<Agent[]> => restGet<unknown>(originV1Url(BASE)).then(normalizeAgents),
|
||||
@@ -551,6 +585,19 @@ export const AgentsApi = {
|
||||
/** Create an agent (`POST /v1/agents`) — only called when the backend is live. */
|
||||
create: (body: NewAgentBody): Promise<unknown> => restPost<unknown>(originV1Url(BASE), body),
|
||||
|
||||
/**
|
||||
* Run an agent once (`POST /v1/agents/:ref/run`) and get the recorded run back.
|
||||
* `ref` is the agent's name or its `agent_…` id — either resolves the same agent.
|
||||
*
|
||||
* THIS MOVES MONEY: the backend authorizes the org's balance BEFORE any inference,
|
||||
* so an unfunded org gets 402 and no free compute. A model failure answers 502 with
|
||||
* the RUN as the body, whose `error` field the transport surfaces as the thrown
|
||||
* message — so a caller that shows `e.message` is already showing the run's own
|
||||
* reason, not a generic transport failure.
|
||||
*/
|
||||
run: (ref: string, input: string): Promise<AgentRun> =>
|
||||
restPost<unknown>(originV1Url(`${agentPath(ref)}/run`), { input }).then(normalizeRun),
|
||||
|
||||
/** Delete an agent (`DELETE /v1/agents/:name`) — keyed by the agent's NAME, never the
|
||||
* display `id`. Only called when the backend is live. */
|
||||
remove: (name: string): Promise<void> => restDelete(originV1Url(agentPath(name))),
|
||||
|
||||
@@ -264,3 +264,54 @@ describe('fetchPlans — honest-empty when gated', () => {
|
||||
expect(plans.map((p) => p.id)).toEqual(['pro'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchCatalog — the routing id is what the gateway serves (tail alias)', () => {
|
||||
const haiku: RichModel = {
|
||||
id: 'anthropic/claude-haiku-4.5',
|
||||
name: 'Anthropic: Claude Haiku 4.5',
|
||||
provider: 'Anthropic',
|
||||
contextWindow: 200000,
|
||||
pricing: { input: 1, output: 5 },
|
||||
}
|
||||
const opus: RichModel = {
|
||||
id: 'anthropic/claude-opus-4.6',
|
||||
name: 'Anthropic: Claude Opus 4.6',
|
||||
provider: 'Anthropic',
|
||||
contextWindow: 200000,
|
||||
pricing: { input: 5, output: 25 },
|
||||
}
|
||||
beforeEach(() => {
|
||||
;(globalThis as { window?: unknown }).window = {
|
||||
location: { origin: ORIGIN, hostname: 'console.hanzo.ai' },
|
||||
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
}
|
||||
vi.stubGlobal('fetch', (url: string) => {
|
||||
const body = url.includes('pricing')
|
||||
? { models: [haiku, opus] }
|
||||
: // The gateway routes the BARE ids, not the bundle's openrouter spelling.
|
||||
{ object: 'list', data: [{ id: 'claude-haiku-4.5' }, { id: 'claude-opus-4.6' }] }
|
||||
return Promise.resolve(new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }))
|
||||
})
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
delete (globalThis as { window?: unknown }).window
|
||||
})
|
||||
|
||||
it('rewrites a bundle id to the live tail so the picker submits what routes', async () => {
|
||||
const cat = await fetchCatalog()
|
||||
const h = cat.find((m) => m.name === 'Anthropic: Claude Haiku 4.5')!
|
||||
expect(h.id).toBe('claude-haiku-4.5') // NOT anthropic/claude-haiku-4.5 — that 404s
|
||||
expect(h.available).toBe(true)
|
||||
// The live-only merge must not append a duplicate bare row for it.
|
||||
expect(cat.filter((m) => modelId(m) === 'claude-haiku-4.5')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('derives premium from frontier pricing when the bundle omits the flag', async () => {
|
||||
const cat = await fetchCatalog()
|
||||
const h = cat.find((m) => modelId(m) === 'claude-haiku-4.5')!
|
||||
const o = cat.find((m) => modelId(m) === 'claude-opus-4.6')!
|
||||
expect(h.premium).toBeFalsy() // $1/$5 — under both bars
|
||||
expect(o.premium).toBe(true) // $5/$25 — the Opus class gates behind a plan
|
||||
})
|
||||
})
|
||||
|
||||
@@ -78,6 +78,22 @@ const FIXTURE_MODELS: RichModel[] = ((catalogFixture as { models?: RichModel[] }
|
||||
const liveKey = (m: { id?: string | null; name?: string }): string =>
|
||||
(m.id ?? m.name ?? '').trim().toLowerCase()
|
||||
|
||||
/**
|
||||
* Premium = the catalog says so, or the price does. The bundle rarely carries
|
||||
* the flag, so frontier-priced models (input ≥ $5/Mtok or output ≥ $25/Mtok —
|
||||
* every Opus generation qualifies at $5/$25 or $15/$75; Sonnet at $3/$15 and
|
||||
* Haiku at $1/$5 stay under both bars) count as premium by price. The flag
|
||||
* drives the picker's Pro gate and keeps default-model away from a 402 on a
|
||||
* trial balance — the GATEWAY remains the enforcement point; this is the
|
||||
* honest rendering of it.
|
||||
*/
|
||||
export function isPremium(m: RichModel): boolean {
|
||||
if (m.premium) return true
|
||||
const input = m.pricing?.input
|
||||
const output = m.pricing?.output
|
||||
return (typeof input === 'number' && input >= 5) || (typeof output === 'number' && output >= 25)
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two catalog sources by stable id, PRIMARY winning field-by-field: a model in
|
||||
* both keeps `primary`'s fields and inherits only the keys `primary` lacks from
|
||||
@@ -166,13 +182,28 @@ export async function fetchCatalog(): Promise<CatalogEntry[]> {
|
||||
const liveSet = new Set(liveArr.map(liveKey).filter(Boolean))
|
||||
// The fixture is the base; live pricing wins where it overlaps, and fills the rest.
|
||||
const catModels = mergeById(cat.models ?? [], FIXTURE_MODELS)
|
||||
const catKeys = new Set(catModels.map((m) => modelId(m).toLowerCase()))
|
||||
const entries: CatalogEntry[] = catModels.map((m) => ({
|
||||
...m,
|
||||
const entries: CatalogEntry[] = catModels.map((m) => {
|
||||
const id = modelId(m).toLowerCase()
|
||||
// Cross-reference by the stable id (third-party) AND the display name (Zen),
|
||||
// so both record shapes resolve their live-availability correctly.
|
||||
available: liveSet.has(modelId(m).toLowerCase()) || liveSet.has((m.name ?? '').toLowerCase()),
|
||||
}))
|
||||
if (liveSet.has(id) || liveSet.has((m.name ?? '').toLowerCase())) {
|
||||
return { ...m, premium: isPremium(m), available: true }
|
||||
}
|
||||
// The gateway routes MANY catalog models under the bare tail of their
|
||||
// openrouter-style id: the bundle says `anthropic/claude-haiku-4.5`, the
|
||||
// gateway serves `claude-haiku-4.5`. The id the picker submits must be the
|
||||
// id the gateway routes — a rich row keeping its bundle spelling is how the
|
||||
// playground offered Claude Haiku 4.5 and then errored running it.
|
||||
const slash = id.lastIndexOf('/')
|
||||
const tail = slash >= 0 ? id.slice(slash + 1) : ''
|
||||
if (tail && liveSet.has(tail)) {
|
||||
const rawId = (m.id ?? '').trim()
|
||||
return { ...m, id: rawId.slice(rawId.lastIndexOf('/') + 1), premium: isPremium(m), available: true }
|
||||
}
|
||||
return { ...m, premium: isPremium(m), available: false }
|
||||
})
|
||||
const catKeys = new Set(catModels.map((m) => modelId(m).toLowerCase()))
|
||||
for (const e of entries) catKeys.add(modelId(e).toLowerCase())
|
||||
// Merge live-only models the pricing overlay doesn't carry — the CURRENT Zen set
|
||||
// (zen5-flash/coder/nano-*) the older bundle omits, and EVERY model when pricing
|
||||
// is down. They are servable now, so they list Available under their family.
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Tools API — the unified tool plane (`GET /v1/tools`).
|
||||
*
|
||||
* ONE flat set of callable names spanning every source the org can reach —
|
||||
* connector actions, user functions, zap-service routes, agents, skills, and the
|
||||
* org's own external MCP servers — deduplicated by name with source precedence
|
||||
* applied server-side. It LISTS; dispatch is `POST /v1/tools/call` and belongs to
|
||||
* whatever runs the agent, not to a form.
|
||||
*
|
||||
* Called SAME-ORIGIN with no prefix (`originV1Url('tools')`), so it rides the
|
||||
* console's own user-bearer proxy: a short-lived user-bound IAM token is minted
|
||||
* server-side and the cloud plane scopes the listing to the caller's org and
|
||||
* project. No credential reaches the browser.
|
||||
*
|
||||
* An org with nothing activated gets `{"tools":[]}` — a REAL empty answer, not a
|
||||
* failure. The agent builder shows that honestly (the field stays typeable) rather
|
||||
* than inventing a tool that would 404 on the first invocation.
|
||||
*/
|
||||
import { restGet, originV1Url } from './client'
|
||||
|
||||
/** Where a tool came from — the plane dedupes across these by name. */
|
||||
export type ToolSource = 'connector' | 'function' | 'zap-service' | 'agent' | 'skill' | 'mcp'
|
||||
|
||||
/** One callable tool as discovery reports it. */
|
||||
export type Tool = {
|
||||
/** The dispatch name — exactly what an agent's `tools` list must carry. */
|
||||
name: string
|
||||
/** Which plane provides it. */
|
||||
source?: ToolSource
|
||||
/** One line on what it does, when the provider carries one. */
|
||||
description?: string
|
||||
/** Whether it is activated for this org+project (an inactive tool lists but won't run). */
|
||||
activated: boolean
|
||||
}
|
||||
|
||||
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined)
|
||||
|
||||
/**
|
||||
* Defensive normalizer. The listing is read from whichever envelope key the plane
|
||||
* uses, and a row missing `name` is dropped — a nameless tool cannot be dispatched,
|
||||
* so surfacing it would offer the user something that cannot work.
|
||||
*/
|
||||
export function normalizeTools(payload: unknown): Tool[] {
|
||||
const rows = Array.isArray(payload)
|
||||
? payload
|
||||
: payload && typeof payload === 'object'
|
||||
? ((payload as Record<string, unknown>).tools ??
|
||||
(payload as Record<string, unknown>).data ??
|
||||
(payload as Record<string, unknown>).items)
|
||||
: undefined
|
||||
if (!Array.isArray(rows)) return []
|
||||
const out: Tool[] = []
|
||||
for (const raw of rows) {
|
||||
if (!raw || typeof raw !== 'object') continue
|
||||
const r = raw as Record<string, unknown>
|
||||
const name = str(r.name)
|
||||
if (!name) continue
|
||||
out.push({
|
||||
name,
|
||||
source: str(r.source) as ToolSource | undefined,
|
||||
description: str(r.description),
|
||||
activated: r.activated === true,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The tool plane's read side. */
|
||||
export const ToolsApi = {
|
||||
/**
|
||||
* Every tool the caller's org and project can reach, each flagged `activated`.
|
||||
* `activatedOnly` narrows to the callable set — the plane tests the raw query
|
||||
* against the literal string "true", so this sends that or nothing at all.
|
||||
*/
|
||||
list: (activatedOnly = false): Promise<Tool[]> =>
|
||||
restGet<unknown>(originV1Url(activatedOnly ? 'tools?activated=true' : 'tools')).then(normalizeTools),
|
||||
}
|
||||
@@ -48,6 +48,46 @@ export const ALWAYS_ON_PRODUCTS: readonly string[] = [
|
||||
'captable', // the org's capitalization ledger — foundational company surface, every org (peer of 'company')
|
||||
]
|
||||
|
||||
/**
|
||||
* THE LAUNCH SET — what a new signup sees on console.hanzo.ai tonight.
|
||||
*
|
||||
* We are launching with hanzo.chat, hanzo.app and the console, so the console
|
||||
* shows exactly what those need and nothing else. Every other product in the
|
||||
* catalog (the whole cloud: compute, data, network, security, web3, the app
|
||||
* suite, the fleet admin) is BETA — present, routable, and invisible until an
|
||||
* org holds the beta flag. A superadmin always sees everything.
|
||||
*
|
||||
* This is an ALLOW-LIST on purpose: a new product added to the catalog is
|
||||
* hidden by DEFAULT and joins the launch only when someone names it here. The
|
||||
* inverse (a deny-list) leaks every future addition onto a customer's first
|
||||
* screen.
|
||||
*
|
||||
* The set: the AI plane the two products run on, the credential to call it,
|
||||
* the money surfaces, org/account management, and the beta door itself.
|
||||
*/
|
||||
export const LAUNCH_PRODUCTS: readonly string[] = [
|
||||
// the console itself
|
||||
'overview', // the home board
|
||||
'beta-features', // the door to everything else — never behind its own flag
|
||||
// the AI plane hanzo.chat + hanzo.app run on
|
||||
'chat', // hanzo.chat
|
||||
'models', // the model catalog
|
||||
'playground', // try a model
|
||||
'api-keys', // the credential both products call with
|
||||
'usage', // what the AI cost
|
||||
'logs', // the request log for those calls
|
||||
// money
|
||||
'billing',
|
||||
'plans',
|
||||
// org + account
|
||||
'settings',
|
||||
'team',
|
||||
'profile',
|
||||
]
|
||||
|
||||
/** True when a product is part of tonight's launch surface. */
|
||||
export const isLaunchProduct = (id: string): boolean => LAUNCH_PRODUCTS.includes(id)
|
||||
|
||||
/** True when a product is always-on (implicit, never stored in `enabled`). */
|
||||
export const isAlwaysOn = (id: string): boolean => ALWAYS_ON_PRODUCTS.includes(id)
|
||||
|
||||
@@ -84,6 +124,23 @@ export function filterEntitled<T extends { id: string }>(
|
||||
* semantics so the UI can optimistically preview a change with the same result the
|
||||
* backend would compute. Remove wins over add for the same id in one patch.
|
||||
*/
|
||||
/**
|
||||
* Keep only the entries a viewer's BETA standing admits. Pure and generic like
|
||||
* `filterEntitled`, and the same one-predicate rule: a superadmin sees
|
||||
* everything, a beta org sees everything, everyone else loses `beta: true`
|
||||
* entries. Fails CLOSED — callers that have not asked the enablement plane
|
||||
* pass `showBeta: false` and beta surfaces stay hidden.
|
||||
*/
|
||||
export function filterBeta<T extends { id: string; beta?: boolean }>(
|
||||
entries: readonly T[],
|
||||
showBeta: boolean,
|
||||
showAdmin: boolean,
|
||||
): T[] {
|
||||
if (showAdmin || showBeta) return [...entries]
|
||||
// Beta is the COMPLEMENT of the launch set: outside it, or stamped.
|
||||
return entries.filter((e) => isLaunchProduct(e.id) && e.beta !== true)
|
||||
}
|
||||
|
||||
export function nextEnabled(current: readonly string[], patch: EntitlementPatch): string[] {
|
||||
const set = new Set<string>(current)
|
||||
for (const id of patch.add ?? []) if (id) set.add(id)
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ function consented(): boolean {
|
||||
|
||||
// ── No publishable ingest key is passed, and that is DELIBERATE ──────────────
|
||||
//
|
||||
// Do not "fix" this by adding a build arg or by reading NEXT_PUBLIC_EVENT_INGEST_KEY
|
||||
// Do not "fix" this by adding a build arg or by reading NEXT_PUBLIC_PUBLISHABLE_KEY
|
||||
// here.
|
||||
//
|
||||
// A `pk-` resolves to exactly ONE org (cloud stamps the tenant from the key), and
|
||||
|
||||
@@ -102,3 +102,17 @@ describe('resolveTour', () => {
|
||||
expect(resolveTour(steps, sig({ hasApiKey: true })).map((s) => s.id)).toEqual(['a']) // has key → dropped
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
// The tour card must never render beside a full-width checklist row — "right
|
||||
// of the row" is the far viewport edge, and step one of every guide tour
|
||||
// rendered clipped off-screen there.
|
||||
it('a checklist tour step places BELOW its row, never beside it', () => {
|
||||
const guide = { id: 'chat', pitch: { title: '', sub: '', points: [] }, steps: [] } as never
|
||||
const resolved = [
|
||||
{ step: { id: 'api-key', title: 't', body: 'b' }, done: false },
|
||||
] as never[]
|
||||
for (const st of buildTourFromSteps(guide, resolved as never)) {
|
||||
expect(st.placement).toBe('bottom')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -136,7 +136,10 @@ export function buildTourFromSteps(guide: ProductGuide, resolved: StepProgress[]
|
||||
target: stepAnchorSelector(guide.id, r.step.id),
|
||||
title: r.step.title,
|
||||
body: r.step.body,
|
||||
placement: 'right' as const,
|
||||
// BELOW the row, never beside it: a checklist row spans the whole card,
|
||||
// so "right of the row" is the far viewport edge — the card rendered
|
||||
// clipped off-screen there, step one of every tour.
|
||||
placement: 'bottom' as const,
|
||||
}))
|
||||
return [...stepTour, ...(guide.tour ?? [])]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { filterBeta, LAUNCH_PRODUCTS } from '~/lib/entitlements'
|
||||
|
||||
// We launch with hanzo.chat, hanzo.app and the console: the launch set is an
|
||||
// ALLOW-LIST, so everything else is beta by default and a NEW catalog entry is
|
||||
// hidden the day it lands. That default is the whole point — pin it.
|
||||
describe('filterBeta — the launch gate', () => {
|
||||
const entries = [
|
||||
{ id: 'chat' },
|
||||
{ id: 'models' },
|
||||
{ id: 'api-keys' },
|
||||
{ id: 'beta-features' },
|
||||
{ id: 'crm' },
|
||||
{ id: 'gpus' },
|
||||
{ id: 'lux-bridge' },
|
||||
{ id: 'a-product-nobody-has-written-yet' },
|
||||
]
|
||||
|
||||
it('shows the launch set and hides everything else', () => {
|
||||
expect(filterBeta(entries, false, false).map((e) => e.id)).toEqual([
|
||||
'chat',
|
||||
'models',
|
||||
'api-keys',
|
||||
'beta-features',
|
||||
])
|
||||
})
|
||||
|
||||
it('a brand-new catalog entry is hidden by DEFAULT, not by remembering to stamp it', () => {
|
||||
const shown = filterBeta([{ id: 'something-new-2027' }], false, false)
|
||||
expect(shown).toEqual([])
|
||||
})
|
||||
|
||||
it('the flag reveals everything; a superadmin never needed it', () => {
|
||||
expect(filterBeta(entries, true, false)).toHaveLength(entries.length)
|
||||
expect(filterBeta(entries, false, true)).toHaveLength(entries.length)
|
||||
})
|
||||
|
||||
it('the beta door itself is in the launch set — otherwise nobody can opt in', () => {
|
||||
expect(LAUNCH_PRODUCTS).toContain('beta-features')
|
||||
})
|
||||
|
||||
it('a stamped entry inside the launch set can still ship dark', () => {
|
||||
expect(filterBeta([{ id: 'chat', beta: true }], false, false)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* useAppsBeta — does this viewer see the beta (Apps) surfaces?
|
||||
*
|
||||
* ONE source of truth: the enablement plane (`/v1/enablement`), the same
|
||||
* self-service opt-in the Beta features module manages, scoped server-side to
|
||||
* the caller's validated org. The gate looks for the `apps` feature (kind
|
||||
* `feature`, id `apps`) being EFFECTIVE for the org — an admin sets it to
|
||||
* `beta` (optionally granting orgs), users opt in where allowed, and this hook
|
||||
* simply reads the resulting truth.
|
||||
*
|
||||
* Fails CLOSED: until the read answers — and whenever it refuses — beta
|
||||
* surfaces stay hidden. A superadmin always sees them (mirror of the `admin`
|
||||
* gate, and the only way the flag surface itself can be administered when the
|
||||
* plane is down). Cached for the session like the org identity is: every nav
|
||||
* surface asks, one request answers.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { EnablementApi } from '~/lib/api/admin-cockpit'
|
||||
|
||||
const APPS_KIND = 'feature'
|
||||
const APPS_ID = 'apps'
|
||||
|
||||
let cached: boolean | null = null
|
||||
let inflight: Promise<boolean> | null = null
|
||||
|
||||
async function readAppsBeta(): Promise<boolean> {
|
||||
if (cached !== null) return cached
|
||||
if (!inflight) {
|
||||
inflight = EnablementApi.view()
|
||||
.then((v) => {
|
||||
const all = [...v.items, ...v.betas]
|
||||
const hit = all.find((i) => i.kind === APPS_KIND && i.id === APPS_ID)
|
||||
cached = Boolean(hit?.effective)
|
||||
return cached
|
||||
})
|
||||
.catch(() => {
|
||||
// A refusal is not an entitlement. Do not cache it — the next mount
|
||||
// may be after sign-in or after the plane recovers.
|
||||
inflight = null
|
||||
return false
|
||||
})
|
||||
}
|
||||
return inflight
|
||||
}
|
||||
|
||||
export function useAppsBeta(isSuperAdmin: boolean): boolean {
|
||||
const [on, setOn] = useState<boolean>(() => isSuperAdmin || cached === true)
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuperAdmin) {
|
||||
setOn(true)
|
||||
return
|
||||
}
|
||||
let live = true
|
||||
readAppsBeta().then((v) => {
|
||||
if (live) setOn(v)
|
||||
})
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [isSuperAdmin])
|
||||
|
||||
return on
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { categoryIsOpen, toggleCategory, type CategoryOpen } from './nav-accordion'
|
||||
import {
|
||||
categoryIsOpen,
|
||||
toggleCategory,
|
||||
productIsOpen,
|
||||
toggleProduct,
|
||||
NAV_OPEN_PREF,
|
||||
NAV_PRODUCT_OPEN_PREF,
|
||||
type CategoryOpen,
|
||||
} from './nav-accordion'
|
||||
|
||||
const ctx = (filtering = false) => ({ filtering })
|
||||
|
||||
@@ -68,3 +76,41 @@ describe('toggleCategory (independent per-section)', () => {
|
||||
expect(next).not.toBe(stored)
|
||||
})
|
||||
})
|
||||
|
||||
describe('productIsOpen / toggleProduct', () => {
|
||||
// Where you are is the one product whose options you are certain to want.
|
||||
it('opens the ACTIVE product and leaves the others closed', () => {
|
||||
expect(productIsOpen({}, 'agents', { filtering: false, active: true })).toBe(true)
|
||||
expect(productIsOpen({}, 'models', { filtering: false, active: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('respects an explicit choice over the active default, in both directions', () => {
|
||||
expect(productIsOpen({ agents: false }, 'agents', { filtering: false, active: true })).toBe(false)
|
||||
expect(productIsOpen({ models: true }, 'models', { filtering: false, active: false })).toBe(true)
|
||||
})
|
||||
|
||||
// The filter narrows PRODUCTS; a matched product's sub-pages are not themselves
|
||||
// matches, so expanding them would push the other hits off screen.
|
||||
it('closes everything while filtering, active or not', () => {
|
||||
expect(productIsOpen({}, 'agents', { filtering: true, active: true })).toBe(false)
|
||||
expect(productIsOpen({ agents: true }, 'agents', { filtering: true, active: true })).toBe(false)
|
||||
})
|
||||
|
||||
// Whichever way the chevron points, the click does that.
|
||||
it('first click on the active one collapses it; on any other one expands it', () => {
|
||||
expect(toggleProduct({}, 'agents', { active: true })).toEqual({ agents: false })
|
||||
expect(toggleProduct({}, 'models', { active: false })).toEqual({ models: true })
|
||||
})
|
||||
|
||||
it('leaves every other product untouched and never mutates the input', () => {
|
||||
const stored = { models: true }
|
||||
expect(toggleProduct(stored, 'agents', { active: false })).toEqual({ models: true, agents: true })
|
||||
expect(stored).toEqual({ models: true })
|
||||
})
|
||||
|
||||
// Products and categories share a preference SHAPE but not a default, and they are
|
||||
// stored under different keys — a product must never inherit a category's open-by-default.
|
||||
it('is keyed apart from the category accordion', () => {
|
||||
expect(NAV_PRODUCT_OPEN_PREF).not.toBe(NAV_OPEN_PREF)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,3 +57,49 @@ export function toggleCategory(stored: CategoryOpen, category: string): Category
|
||||
const current = stored[category] === undefined ? true : stored[category]
|
||||
return { ...stored, [category]: !current }
|
||||
}
|
||||
|
||||
// ── Products, which expand IN PLACE ─────────────────────────────────────────
|
||||
//
|
||||
// A product with sub-pages expands beneath its own row, so its options appear
|
||||
// without the rest of the catalog disappearing. This replaced a DRILL — clicking a
|
||||
// product used to swap the entire rail for that product's sub-nav, behind a "Back to
|
||||
// all products" button. The options were the same either way; what the drill took
|
||||
// away was every other product, which is exactly what a person needs to see when the
|
||||
// reason they clicked was to compare or to move on somewhere else.
|
||||
//
|
||||
// The default here is the OPPOSITE of a category's, and deliberately: categories are
|
||||
// few and describe the whole catalog, so they open; products are many and each brings
|
||||
// four to eight rows, so opening them all would bury the catalog under its own detail.
|
||||
|
||||
/** Preference key for which products are expanded in the rail. */
|
||||
export const NAV_PRODUCT_OPEN_PREF = 'navProductsOpen'
|
||||
|
||||
/**
|
||||
* Whether a product's sub-pages render EXPANDED:
|
||||
* - while FILTERING: closed. The filter narrows PRODUCTS, and a matched product's
|
||||
* sub-pages are not themselves matches — expanding them would push the other hits
|
||||
* off screen;
|
||||
* - the ACTIVE product: open, unless the user explicitly collapsed it. Where you are
|
||||
* is the one place whose options you are certain to want;
|
||||
* - otherwise: the user's explicit choice, else CLOSED.
|
||||
*/
|
||||
export function productIsOpen(
|
||||
stored: CategoryOpen,
|
||||
id: string,
|
||||
ctx: { filtering: boolean; active: boolean },
|
||||
): boolean {
|
||||
if (ctx.filtering) return false
|
||||
const v = stored[id]
|
||||
return v === undefined ? ctx.active : v
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle one product's expansion (pure + immutable). The stored value is what the
|
||||
* product is being toggled AWAY from, so the first click on the active product
|
||||
* collapses it and the first click on any other one expands it — in both cases the
|
||||
* click does the thing the chevron was pointing at.
|
||||
*/
|
||||
export function toggleProduct(stored: CategoryOpen, id: string, ctx: { active: boolean }): CategoryOpen {
|
||||
const current = stored[id] === undefined ? ctx.active : stored[id]
|
||||
return { ...stored, [id]: !current }
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ import { Users,
|
||||
} from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { config, type BrandId, type ShellId } from '~/config'
|
||||
import { ALWAYS_ON_PRODUCTS, filterEntitled } from '~/lib/entitlements'
|
||||
import { ALWAYS_ON_PRODUCTS, filterBeta, filterEntitled, isLaunchProduct } from '~/lib/entitlements'
|
||||
import { type ProductCategory, categoryOrder, categoriesForBrand, categoryInBrand } from './brand-scope'
|
||||
import { shellFor, isProductShell } from './shell'
|
||||
import { ProvidersModule } from '~/components/products/ProvidersModule'
|
||||
@@ -420,6 +420,14 @@ type CatalogBase = {
|
||||
docs?: string
|
||||
/** Admin-gated surface (shown with a lock hint; access enforced server-side). */
|
||||
admin?: boolean
|
||||
/**
|
||||
* Beta (early-access) product — hidden from every nav, palette, discovery
|
||||
* panel and search until the caller's ORG holds the `apps` beta through the
|
||||
* enablement plane (kind `feature`, id `apps`) — the same self-service
|
||||
* opt-in the Beta features module manages. Superadmins always see them, and
|
||||
* the gate fails CLOSED: no enablement read, no beta surfaces.
|
||||
*/
|
||||
beta?: boolean
|
||||
/**
|
||||
* Per-brand scope — the brands whose console shows this entry (`entryInBrandScope`).
|
||||
* OMIT for a brand-agnostic entry (the default: shown on every brand its category
|
||||
@@ -1228,6 +1236,10 @@ export const catalog: CatalogEntry[] = [
|
||||
{ path: ':tab', component: AgentsModule },
|
||||
],
|
||||
subpages: [
|
||||
// The guided way in: describe an agent or start from a template, configure it in
|
||||
// the ONE builder, run it, and take the call away. Leads the sub-nav because it
|
||||
// is where someone with no agents yet should land.
|
||||
{ slug: 'quickstart', label: 'Quickstart' },
|
||||
{ slug: 'status', label: 'Status' },
|
||||
{ slug: 'logs', label: 'Logs' },
|
||||
{ slug: 'metrics', label: 'Metrics' },
|
||||
@@ -3764,6 +3776,17 @@ export const catalogByCategory = (): { category: ProductCategory; entries: Catal
|
||||
/** An admin-only (global / Hanzo-managed) entry — hidden from a customer's nav. */
|
||||
export const isAdminEntry = (e: CatalogEntry): boolean => e.admin === true
|
||||
|
||||
/**
|
||||
* A BETA entry — hidden until the org holds the beta flag (or is a superadmin).
|
||||
*
|
||||
* Beta is the COMPLEMENT of the launch set, not a per-entry stamp: we are
|
||||
* launching with hanzo.chat, hanzo.app and the console, so everything outside
|
||||
* `LAUNCH_PRODUCTS` is beta by default and a new catalog entry is hidden the
|
||||
* day it lands. `beta: true` still forces the flag on for an entry inside the
|
||||
* launch set, which is how a launch surface can ship dark.
|
||||
*/
|
||||
export const isBetaEntry = (e: CatalogEntry): boolean => e.beta === true || !isLaunchProduct(e.id)
|
||||
|
||||
/**
|
||||
* Per-brand category scope — the ONE knob that makes each brand's console show
|
||||
* the right surfaces. `hanzo` is the full AI cloud. The sovereign-chain brands
|
||||
@@ -3800,6 +3823,9 @@ export const inBrand = (e: CatalogEntry): boolean =>
|
||||
export const visibleCatalog = (
|
||||
showAdmin: boolean,
|
||||
enabled?: string[] | null,
|
||||
// Fails CLOSED on purpose: a caller that has not asked the enablement plane
|
||||
// does not show beta surfaces.
|
||||
showBeta = false,
|
||||
): CatalogEntry[] => {
|
||||
// Product-shell face (billing / marketing / ads / social / sentry host, or an
|
||||
// override): the SAME console image, scoped to ONE product FACE — its root module
|
||||
@@ -3816,7 +3842,7 @@ export const visibleCatalog = (
|
||||
// belong to their face, not the general nav (e.g. the sentry panels are the o11y
|
||||
// surfaces' Sentry twin, shown only on sentry.<brand>). marketing/ads/social carry
|
||||
// NO `e.shell` (normal Apps products), so they ALSO show in the full console.
|
||||
const byAdmin = (showAdmin ? catalog : catalog.filter((e) => !isAdminEntry(e)))
|
||||
const byAdmin = filterBeta(showAdmin ? catalog : catalog.filter((e) => !isAdminEntry(e)), showBeta, showAdmin)
|
||||
.filter((e) => !e.shell)
|
||||
.filter(inBrand)
|
||||
// ENTITLEMENT GATE (customer only): out-of-box an org sees ONLY the products it has
|
||||
@@ -3830,8 +3856,9 @@ export const visibleCatalog = (
|
||||
export const visibleCatalogByCategory = (
|
||||
showAdmin: boolean,
|
||||
enabled?: string[] | null,
|
||||
showBeta = false,
|
||||
): { category: ProductCategory; entries: CatalogEntry[] }[] => {
|
||||
const visible = visibleCatalog(showAdmin, enabled)
|
||||
const visible = visibleCatalog(showAdmin, enabled, showBeta)
|
||||
// In a product-shell face the root module IS the whole catalog — surface it as a
|
||||
// single group regardless of the brand's category order (its category may be
|
||||
// outside the brand's normal set). ONE branch for EVERY face.
|
||||
|
||||
@@ -87,12 +87,14 @@ function scoreDestination(q: string, d: Destination): number {
|
||||
* deep sub-page jumps ("queues" → Compute › Tasks › Queues). `showAdmin` gates
|
||||
* admin-only surfaces so a customer can't jump to what they can't see.
|
||||
*/
|
||||
export function searchDestinations(query: string, showAdmin = true, enabled?: string[] | null): Destination[] {
|
||||
export function searchDestinations(query: string, showAdmin = true, enabled?: string[] | null, showBeta = false): Destination[] {
|
||||
const q = query.trim().toLowerCase()
|
||||
// Scope to the visible catalog (brand + billing-only shell + entitlements), then
|
||||
// gate admin — so ⌘K jumps match exactly what the nav shows (billing-only offers
|
||||
// only billing; a customer only what their org has enabled).
|
||||
const all = destinationsFor(visibleCatalog(showAdmin, enabled), showAdmin)
|
||||
// only billing; a customer only what their org has enabled). A TYPED query is
|
||||
// DISCOVERY: the entitlement scope opens to the whole catalog — searching is
|
||||
// for finding what you do not have yet — while admin and beta keep holding.
|
||||
const all = destinationsFor(visibleCatalog(showAdmin, q ? null : enabled, showBeta), showAdmin)
|
||||
if (!q) return all.filter((d) => d.kind === 'product')
|
||||
return all
|
||||
.map((d) => ({ d, s: scoreDestination(q, d) }))
|
||||
|
||||
@@ -80,6 +80,19 @@ describe('allowCloudSurface', () => {
|
||||
expect(allowCloudSurface('v1/agents/agent-1/runs')).toBe(true)
|
||||
})
|
||||
|
||||
// The tool plane is admitted so the agent builder can offer an org's REAL tool
|
||||
// names. Discovery only: a head admits every sub-path, and `POST /v1/tools/call`
|
||||
// RUNS a tool — that belongs to whatever runs an agent, never to a browser tab.
|
||||
it('admits tool discovery but refuses the dispatch door', () => {
|
||||
expect(CLOUD_HEADS).toContain('tools')
|
||||
expect(allowCloudSurface('v1/tools')).toBe(true)
|
||||
expect(allowCloudSurface('v1/tools?activated=true')).toBe(true)
|
||||
expect(allowCloudSurface('v1/tools/catalog')).toBe(true)
|
||||
expect(allowCloudSurface('v1/tools/call')).toBe(false)
|
||||
expect(allowCloudSurface('/v1/tools/call')).toBe(false)
|
||||
expect(allowCloudSurface('v1/tools/call?x=1')).toBe(false)
|
||||
})
|
||||
|
||||
it('admits the evals facade (scores/datasets/rubrics/evaluators/runs)', () => {
|
||||
expect(CLOUD_HEADS).toContain('evals')
|
||||
for (const sub of ['scores', 'datasets', 'rubrics', 'evaluators', 'runs']) {
|
||||
|
||||
@@ -33,6 +33,13 @@ export const CLOUD_HEADS: readonly string[] = [
|
||||
'functions',
|
||||
'prompts',
|
||||
'agents',
|
||||
// The unified tool plane (cloud apps/tools): /v1/tools — discovery across every
|
||||
// source (connector actions, functions, zap-service routes, agents, skills, the
|
||||
// org's own MCP servers), deduplicated by name. `scopeOf` derives the org+project
|
||||
// from the Bearer owner and 403s a cookie-only call, so it routes through /v1
|
||||
// exactly like agents/prompts. Discovery only — `/v1/tools/call` is refused below,
|
||||
// because running a tool belongs to whatever runs an agent, not to a browser tab.
|
||||
'tools',
|
||||
// Login manager (cloud clients/link): /v1/links[/…] — the org+user-scoped registry
|
||||
// of which AI provider accounts are signed in on which machines + their usage. The
|
||||
// handler resolves org from the Bearer owner + the user from the validated subject
|
||||
@@ -395,7 +402,15 @@ export function v1Head(path: string): string | null {
|
||||
* rule would break a live surface while claiming to preserve a property that never
|
||||
* covered it. Defense in depth — the backend gates cross-tenant reads on its own.
|
||||
*/
|
||||
const REFUSED_SUBPATHS: readonly RegExp[] = [/^v1\/ai\/stores\/global(?:$|[/?#])/]
|
||||
const REFUSED_SUBPATHS: readonly RegExp[] = [
|
||||
/^v1\/ai\/stores\/global(?:$|[/?#])/,
|
||||
// The tool plane's DISPATCH door. `tools` is allow-listed for discovery — the agent
|
||||
// builder needs to offer the org's real tool names — but a head admits every
|
||||
// sub-path, and `POST /v1/tools/call` RUNS a tool. Executing one belongs to whatever
|
||||
// runs an agent, never to a form in a browser tab, so the console's proxy is a
|
||||
// read-only window onto the plane.
|
||||
/^v1\/tools\/call(?:$|[/?#])/,
|
||||
]
|
||||
|
||||
export function allowCloudSurface(path: string): boolean {
|
||||
const rel = path.replace(/^\/+/, '')
|
||||
|
||||
Reference in New Issue
Block a user