mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
The model word under the composer opens @hanzo/build's own menu since 0.2.73 (Search models, Enso's row, model rows carrying data-model and data-paused), not a panel with @hanzo/ui ModelPicker inside it, and effort is a word of its own. chat.ts reads that menu; picker, limits, upgrade and named measure it. ModelPicker's research-preview row has no counterpart in build's menu, which lists what /v1/models serves, so picker.spec stops asserting it and LLM.md says where Zen 7 is asked for instead. The usage bars are UsageMeter rows keyed by class (usage-meter-row), as the settings gates already read them. An empty chat shows @hanzo/build Suggestions since @hanzo/rooms 0.1.83, and a starter does its act on the press; starters and local-cloud hold that, with the kept rows answered so no model is asked. Agents are seated from + › Agents, which lists the org's own agents; together.spec gives the org the core team's rows and holds the same room, briefs and names. The thread on /models is found by its accessible name: rooms' Ask no longer draws a placeholder, so TryChat stops passing one. Claude-Session: https://claude.ai/code/session_01BLLMyBFSvAfCt7ZGqYuiEz
105 lines
5.1 KiB
TypeScript
105 lines
5.1 KiB
TypeScript
import type { Page } from '@playwright/test'
|
|
import { answer, signIn } from '../session'
|
|
import { config, person } from './heard'
|
|
import { PLANS } from './api'
|
|
|
|
/**
|
|
* A signed-in reader at /chat's composer, and the model chip beside send
|
|
* (@hanzo/build's Tune, whose choice is kept as `hanzo.mind`).
|
|
*
|
|
* Three models are served: Enso's free route, Enso's router, and one Enso does
|
|
* not route to. The Free plan runs the first two.
|
|
*/
|
|
|
|
export const PAID = { id: 'zen-6', name: 'Zen 6', family: 'zen', class: 'ours' }
|
|
export const FREE = { id: 'enso-free', name: 'Enso Free', family: 'enso', class: 'free' }
|
|
/** On the Free plan's row, though `isFree` does not pass it. */
|
|
export const AUTO = { id: 'enso-auto', name: 'Enso Auto', family: 'enso', class: 'ours' }
|
|
|
|
/** Where the browser keeps the reader's model and effort (@hanzo/build/mind). */
|
|
const MIND = 'hanzo.mind'
|
|
|
|
/** The model the browser keeps as the reader's choice, or null when none is kept. */
|
|
export const kept = (page: Page): Promise<string | null> =>
|
|
page.evaluate((key) => {
|
|
try {
|
|
return (JSON.parse(localStorage.getItem(key) ?? 'null') as { model?: string } | null)?.model ?? null
|
|
} catch {
|
|
return null
|
|
}
|
|
}, MIND)
|
|
|
|
/**
|
|
* Chat ready for a keystroke: the app's frame in Chat, with the composer's field
|
|
* drawn. Drawn means hydrated: the app renders on the client only
|
|
* (app/_web.tsx), so nothing of it is in the export to be typed into early. The
|
|
* field, not send: an empty composer's round button is voice mode, and send is
|
|
* drawn only once there are words.
|
|
*/
|
|
export async function ready(page: Page, timeout = 30_000) {
|
|
await page.locator('[data-slot="frame"][data-app="chat"] [data-slot="composer-field"]').first().waitFor({ timeout })
|
|
}
|
|
|
|
/** A signed-in reader in their personal organization, on `tier`, with `chosen` as their model and `more` served beside the three. */
|
|
export async function arrive(page: Page, base: string, tier: string, chosen: string, more: { id: string; name: string; owned_by?: string; class?: string; family?: string }[] = []) {
|
|
await signIn(page, { sub: 'bob', name: 'bob', email: '[email protected]', org: 'bob' })
|
|
await person(page)
|
|
await config(page)
|
|
await page.route('https://www.googletagmanager.com/**', (route) =>
|
|
route.fulfill({ status: 200, contentType: 'text/javascript', body: '' }),
|
|
)
|
|
await page.route('https://connect.facebook.net/**', (route) =>
|
|
route.fulfill({ status: 200, contentType: 'text/javascript', body: '' }),
|
|
)
|
|
await answer(page, '/v1/billing/tier', {
|
|
user: 'bob',
|
|
tier: { name: tier, displayName: tier, dailyCreditsCents: 0, maxAgents: 1, unlimitedAgents: false },
|
|
balance: { currency: 'usd', prepaidAvailable: 0, creditsRemaining: 0, dailyRemaining: 0, effectiveAvailable: 0 },
|
|
})
|
|
await answer(page, '/v1/billing/balance', { available: 0 })
|
|
// The plan authority, as the gates answer it: the upgrade quotes from it.
|
|
await answer(page, '/v1/billing/plans', PLANS)
|
|
await answer(page, '/v1/iam/organizations', {
|
|
organizations: [{ owner: 'admin', name: 'bob', displayName: 'bob', isPersonal: true }],
|
|
})
|
|
await answer(page, '/v1/models', {
|
|
object: 'list',
|
|
data: [FREE, AUTO, PAID, ...more].map((m) => ({ object: 'model', owned_by: 'hanzo', ...m })),
|
|
})
|
|
await answer(page, '/v1/agents', { agents: [] })
|
|
await answer(page, '/v1/agents/chat/conversations', { conversations: [] })
|
|
await answer(page, '/v1/agents/sessions', { sessions: [] })
|
|
await answer(page, '/v1/projects', [])
|
|
await answer(page, '/v1/team/rooms', { rooms: [] })
|
|
await answer(page, '/v1/channels/inbox', { items: [] })
|
|
await page.addInitScript(([key, model]) => localStorage.setItem(key, JSON.stringify({ model, effort: 'medium' })), [MIND, chosen] as const)
|
|
await page.goto(`${base}/chat`)
|
|
await ready(page)
|
|
}
|
|
|
|
/**
|
|
* The model's word under the composer: the button named `Model: <name>` inside
|
|
* @hanzo/build Tune's `[data-slot="mind"]`. Since @hanzo/build 0.2.73 it opens
|
|
* the model menu directly — no panel with a picker one press further in — and
|
|
* the effort is a word of its own beside it (`Effort: <pace>`).
|
|
*/
|
|
export const chip = (page: Page) => page.locator('[data-slot="mind"] button')
|
|
|
|
/** The effort's word beside the model's, drawn while the model takes an effort. */
|
|
export const effort = (page: Page) => page.locator('[data-slot="tune"]').getByRole('button', { name: /^Effort: / })
|
|
|
|
/** The model menu the chip opens: Search models, Enso's row, then the catalog by family. */
|
|
export const panel = (page: Page) => page.locator('[data-slot="model-menu"]')
|
|
|
|
/** Enso's row, the router, at the head of the menu on any plan but Free. */
|
|
export const enso = (page: Page) => panel(page).getByRole('menuitem', { name: 'Enso Picks the model for each message and step' })
|
|
|
|
/** A model's row in the open menu. */
|
|
export const row = (page: Page, id: string) => panel(page).locator(`[data-slot="model-row"][data-model="${id}"]`)
|
|
|
|
/** Opens the model menu and picks `id`: Enso's own row for the router, else the model's. */
|
|
export async function pick(page: Page, id: string) {
|
|
await chip(page).click()
|
|
await (id === AUTO.id ? enso(page) : row(page, id)).click()
|
|
}
|