Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba571999a2 | ||
|
|
594314d351 | ||
|
|
c709b1f96a | ||
|
|
54d6ce9959 | ||
|
|
8a7ae9be23 | ||
|
|
19cebcb92a |
+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.
|
||||
|
||||
@@ -114,3 +114,52 @@ test('phone: it stacks and the body never scrolls sideways', async ({ browser })
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/console",
|
||||
"version": "8.5.61",
|
||||
"version": "8.5.62",
|
||||
"packageManager": "pnpm@11.17.0",
|
||||
"private": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
|
||||
@@ -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,7 @@ 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'
|
||||
@@ -136,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()
|
||||
|
||||
@@ -228,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
|
||||
+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
|
||||
|
||||
Reference in New Issue
Block a user