diff --git a/.changeset/openai-current-models.md b/.changeset/openai-current-models.md new file mode 100644 index 000000000..4df0d14c9 --- /dev/null +++ b/.changeset/openai-current-models.md @@ -0,0 +1,8 @@ +--- +'@adminium/llm': patch +'@adminium/dashboard': patch +--- + +OpenAI's current models work again. The Designer's requests with tools were refused by every OpenAI model that reasons (`gpt-6-luna` and its family); it now asks with the model's reasoning off, which is how OpenAI's Chat Completions address takes tools from such a model. The assistant and schema enrichment were refused too (`max_tokens`, then temperature 0): they now send `max_completion_tokens`, and keep temperature 0 by switching the reasoning off where a model asks for that. OpenAI-compatible servers are sent what they were sent before. + +The model lists shown before a provider answers are the current ones (Claude Sonnet 5.5, Haiku 5.5, Opus 5.5, Fable 5.1; GPT-6.1 Sol, GPT-6 Luna, GPT-6 Astra), and OpenAI's list no longer offers its models that do not chat (speech, live audio, pictures, search). Both were tried through the Designer on real requests: see "Models tried" in the Designer's guide. diff --git a/.changeset/phone-app.md b/.changeset/phone-app.md new file mode 100644 index 000000000..3c94cb6ee --- /dev/null +++ b/.changeset/phone-app.md @@ -0,0 +1,15 @@ +--- +'@adminium/server': patch +'@adminium/dashboard': patch +'@adminium/manifest': patch +'@adminium/meta': patch +'@adminium/i18n': patch +--- + +An app can have a phone app for its customers: a third side, `mobile`, an Expo (React Native) project in `apps//mobile/`. `adminium app new --mobile` writes one, and the Designer's "what to build" menu has **Mobile**. Adding one installs Expo's packages (about 350 MB), and a person is asked first; `adminium app approve-build` gives that yes for a phone side that came from elsewhere. + +In the Designer the preview has a **Phone app** side: the app in a phone's frame, built for the web by Expo. **Open on a phone** shows a code that a phone with Expo Go, on the same network, scans to run the real app against this computer's data. Nothing is sent to anyone else, and the Designer itself stays on this computer. A phone side is a preview of a project being designed or developed: `adminium start`, a packed app and an installed app never serve it. + +An app with a phone side gets an **app key** (`adm_app_…`) beside its customer key when it is installed. It reaches what the customer key reaches, needs no `Origin`, and is refused from a browser. Studio's API keys page lists it as "PHONE APP"; an update that adds a phone side asks before making it. A server key is now also refused when a request carries `Sec-Fetch-Dest`. + +New skill for coding agents: `adminium-mobile`. diff --git a/apps/dashboard/src/designer/api.ts b/apps/dashboard/src/designer/api.ts index ef843c690..d5bb9825a 100644 --- a/apps/dashboard/src/designer/api.ts +++ b/apps/dashboard/src/designer/api.ts @@ -11,7 +11,29 @@ import { api, ApiError, csrfHeaders } from '../app/api.js'; const BASE = '/api/v1/designer'; -export type DesignerTarget = 'auto' | 'dashboard' | 'web'; +export type DesignerTarget = 'auto' | 'dashboard' | 'web' | 'mobile'; + +/** How an app's phone side stands (mirrors the server's `designerPhoneReply`). */ +export type PhoneStanding = + | { state: 'none' } + | { state: 'shape' | 'no-packages' | 'failed'; words: string } + /** `packages` are what its package.json asks for, by name: what the yes installs. */ + | { state: 'unapproved'; words: string; packages: string[] } + | { state: 'built'; build: string } + | { state: 'working'; step: 'adding' | 'installing' | 'building' }; + +/** The door a real phone comes through. `url` carries the code a phone scans. */ +export type PhoneDoorState = + | { state: 'off' } + | { state: 'starting'; app: string } + | { state: 'open'; app: string; url: string; qr: string; address: string; sdk: string | null; connected: boolean; phoneSdk: string | null; lastSeen: number | null } + | { state: 'closed'; app: string; why: 'stopped' | 'idle' | 'network' | 'failed'; words: string }; + +export interface PhoneStatus { + phone: PhoneStanding; + door: PhoneDoorState; + addresses: { address: string; name: string }[]; +} export interface DesignerSession { id: string; @@ -247,7 +269,7 @@ export type FolderHold = 'save' | 'style' | 'restore' | 'start'; /** `by: 'person'`: caused from a page, outside any turn. It carries the last turn's number and belongs to none. */ export type DesignerEvent = DesignerEventBody & { seq: number; turn: number; at: number; by?: 'person' }; -export type DesignerFileGroup = 'customer' | 'staff' | 'dashboard' | 'settings'; +export type DesignerFileGroup = 'customer' | 'staff' | 'mobile' | 'dashboard' | 'settings'; /** A file a person may open: where it is in the project, what it is called on the page, and what it holds now (by hash). */ export interface DesignerFile { path: string; @@ -408,6 +430,12 @@ export const designerApi = { saveFiles: (id: string, files: readonly { path: string; content: string; base: string }[]) => api.put(`${BASE}/sessions/${id}/files`, { files }), /** One of the app's files a person may open, whole, with the hash it has now. */ fileContent: (id: string, path: string) => api.get<{ path: string; content: string; hash: string }>(`${BASE}/sessions/${id}/files/content?path=${encodeURIComponent(path)}`), + /** The app's phone side: how it stands, the door a phone comes through, this computer's addresses. */ + phone: (id: string) => api.get(`${BASE}/sessions/${id}/phone`), + /** A person's yes: add the phone app (or get its packages), build it and apply the app. Answers at once; watch `phone`. */ + phoneAdd: (id: string) => api.post(`${BASE}/sessions/${id}/phone/add`, {}), + phoneOpen: (id: string, address: string) => api.post(`${BASE}/sessions/${id}/phone/open`, { address }), + phoneStop: (id: string) => api.post(`${BASE}/sessions/${id}/phone/stop`, {}), previewTicket: (id: string, to: string) => api.post<{ url: string; origin: string; seenAs?: string[] }>(`${BASE}/sessions/${id}/preview-ticket`, { to }), /** A one-use link that signs this project's local owner in from the system's browser, landing on `to` (a path). */ browserLink: (to: string) => api.post<{ data: { url: string } }>('/api/v1/auth/design-link', { to }), diff --git a/apps/dashboard/src/designer/build/AddressBar.tsx b/apps/dashboard/src/designer/build/AddressBar.tsx index 045873638..f371c5ce5 100644 --- a/apps/dashboard/src/designer/build/AddressBar.tsx +++ b/apps/dashboard/src/designer/build/AddressBar.tsx @@ -27,6 +27,9 @@ export function addressLabel(side: PreviewSide): string { return t('designer:preview.addressStaff', 'Address on the staff side'); case 'customer': return t('designer:preview.addressCustomer', 'Address on the customer side'); + // A phone app has no addresses: its bar is not drawn, and this is never read. + case 'mobile': + return ''; } } diff --git a/apps/dashboard/src/designer/build/BuildPage.tsx b/apps/dashboard/src/designer/build/BuildPage.tsx index f72b297bb..aa0b2feb3 100644 --- a/apps/dashboard/src/designer/build/BuildPage.tsx +++ b/apps/dashboard/src/designer/build/BuildPage.tsx @@ -134,6 +134,8 @@ export function BuildPage({ sessionId }: { sessionId: string }): ReactNode { void queryClient.invalidateQueries({ queryKey: designerKeys.apps }); void queryClient.invalidateQueries({ queryKey: designerKeys.architecture(sessionId) }); void queryClient.invalidateQueries({ queryKey: designerKeys.files(sessionId) }); + // A turn may have added the phone app, or built it again. + void queryClient.invalidateQueries({ queryKey: ['designer', 'phone', sessionId] }); // The preview's list of installed apps: a live `app-changed` can be missed, a turn's end cannot. void queryClient.invalidateQueries({ queryKey: ['designer', 'installed'] }); }; diff --git a/apps/dashboard/src/designer/build/CodeTab.tsx b/apps/dashboard/src/designer/build/CodeTab.tsx index c3db122b2..bb042b5aa 100644 --- a/apps/dashboard/src/designer/build/CodeTab.tsx +++ b/apps/dashboard/src/designer/build/CodeTab.tsx @@ -21,6 +21,7 @@ import type { CodeFiles, CodeLock } from './useCodeFiles.js'; function groupTitle(key: DesignerFileGroup): string { if (key === 'customer') return t('designer:code.groupCustomer', 'Customer side'); if (key === 'staff') return t('designer:code.groupStaff', 'Staff side'); + if (key === 'mobile') return t('designer:code.groupMobile', 'Phone app'); if (key === 'dashboard') return t('designer:code.groupDashboard', 'Dashboard side'); return t('designer:code.groupSettings', 'Design and settings'); } diff --git a/apps/dashboard/src/designer/build/MoreMenu.tsx b/apps/dashboard/src/designer/build/MoreMenu.tsx index 394d270d8..b61cf3a85 100644 --- a/apps/dashboard/src/designer/build/MoreMenu.tsx +++ b/apps/dashboard/src/designer/build/MoreMenu.tsx @@ -35,7 +35,8 @@ export function MoreMenu({ onSide: (side: string) => void; seen: { label: string; tip: string } | null; sees: boolean; - onSees: (on: boolean) => void; + /** Absent for a side the Designer does not look at (a phone app): the switch is then not in the menu. */ + onSees?: ((on: boolean) => void) | undefined; /** Absent where there is nowhere to open it. */ onNewTab?: (() => void) | undefined; }): ReactNode { @@ -69,6 +70,7 @@ export function MoreMenu({ ) : null} + {onSees === undefined ? null : ( {t('designer:preview.sees', 'The Designer looks at the page after it builds')} + )} {rows && onNewTab !== undefined ? ( } onSelect={onNewTab} className={MENU_ROW}> {t('designer:preview.newTab', 'Open in a new tab')} diff --git a/apps/dashboard/src/designer/build/PhonePanel.tsx b/apps/dashboard/src/designer/build/PhonePanel.tsx new file mode 100644 index 000000000..1a539b97e --- /dev/null +++ b/apps/dashboard/src/designer/build/PhonePanel.tsx @@ -0,0 +1,283 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** + * The Phone side of the preview: the app's phone app, built for the web by + * Expo and shown in a phone's frame; what stands in for it while there is + * none to show (not added, packages missing, did not build); and "Open on a + * phone", the code a real phone scans. + * + * This side is built from the build page's own parts: the preview's dotted + * ground and device, the failed-build card, the kit's Modal. + */ +import { useId, useState, type ReactNode } from 'react'; +import { Check, Copy, LoaderCircle, QrCode, Smartphone, WandSparkles } from 'lucide-react'; +import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from '@adminium/ui'; + +import { t } from '../../i18n/t.js'; +import type { PhoneDoorState, PhoneStanding } from '../api.js'; +import type { PreviewModel } from './usePreview.js'; + +const GROUND = 'flex min-h-0 flex-1 flex-col items-center justify-center gap-3 overflow-hidden rounded-[14px] bg-surface-3 bg-[radial-gradient(var(--border-strong)_1px,transparent_1px)] bg-[size:16px_16px] p-[22px]'; + +function workingWords(step: 'adding' | 'installing' | 'building'): string { + switch (step) { + case 'adding': + return t('designer:phone.adding', 'Adding the phone app…'); + case 'installing': + return t('designer:phone.installing', 'Installing Expo’s packages (about 350 MB)…'); + case 'building': + return t('designer:phone.building', 'Building the phone app…'); + } +} + +/** What stands where the phone would be, while there is no phone app to show. */ +function Standing({ phone, onAdd, onFix, error, running }: { phone: PhoneStanding; onAdd: () => void; onFix: (message: string) => void; error: string | null; running: boolean }): ReactNode { + if (phone.state === 'working') { + return ( +
+
+ ); + } + const asks = phone.state === 'none' || phone.state === 'unapproved'; + const title = + phone.state === 'none' + ? t('designer:phone.addTitle', 'Add a phone app?') + : phone.state === 'unapproved' + ? t('designer:phone.approveTitle', 'This app has a phone app. Build it on this computer?') + : phone.state === 'no-packages' + ? t('designer:phone.packagesTitle', 'The phone app’s packages are not installed.') + : phone.state === 'shape' + ? t('designer:phone.shapeTitle', 'The phone app’s folder cannot be built as it is.') + : t('designer:phone.failedTitle', 'The phone app did not build.'); + const words = phone.state === 'failed' || phone.state === 'shape' ? phone.words : null; + return ( +
+
+ + {title} +
+ {asks || phone.state === 'no-packages' ? ( +

+ {t('designer:phone.addLine', 'It installs Expo’s packages (about 350 MB, about a minute) and builds with Expo on this computer. This needs the network.')} +

+ ) : null} + {phone.state === 'unapproved' && phone.packages.length > 0 ? ( +
+

{t('designer:phone.packages', 'It asks for these packages:')}

+ + {phone.packages.join('\n')} + +
+ ) : null} + {words === null ? null : ( + + {words} + + )} + {error === null ? null : ( +

+ {error} +

+ )} +
+ {phone.state === 'shape' ? null : ( + + )} + {phone.state === 'failed' || phone.state === 'shape' ? ( + + ) : null} +
+ {running ?

{t('designer:phone.waitTurn', 'The Designer is working. This waits until it is done.')}

: null} +
+ ); +} + +/** The code's picture: the server drew it; it is shown as an image, never put into the page as markup. */ +function Code({ svg, label }: { svg: string; label: string }): ReactNode { + return {label}; +} + +function DoorDialog({ door, addresses, error, open, onOpenChange, onOpen, onStop }: { door: PhoneDoorState; addresses: readonly { address: string; name: string }[]; error: string | null; open: boolean; onOpenChange: (open: boolean) => void; onOpen: (address: string) => void; onStop: () => void }): ReactNode { + const pick = useId(); + const [address, setAddress] = useState(null); + const [copied, setCopied] = useState(false); + const chosen = address !== null && addresses.some((entry) => entry.address === address) ? address : (addresses[0]?.address ?? null); + const copy = (text: string): void => { + void navigator.clipboard.writeText(text).then( + () => { + setCopied(true); + setTimeout(() => setCopied(false), 1600); + }, + () => undefined, + ); + }; + return ( + + } title={t('designer:phone.doorTitle', 'Open on a phone')} /> + + {door.state === 'open' ? ( + <> +
+ +
    +
  1. {door.sdk === null ? t('designer:phone.stepGo', 'Install Expo Go on the phone.') : t('designer:phone.stepGoSdk', 'Install Expo Go on the phone (the one for SDK {sdk}).', { sdk: door.sdk })}
  2. +
  3. {t('designer:phone.stepNetwork', 'Put the phone on the same network as this computer.')}
  4. +
  5. {t('designer:phone.stepScan', 'Scan the code with the phone’s camera, or in Expo Go.')}
  6. +
+
+
+ + {door.url} + + + + {copied ? t('designer:phone.copied', 'Copied') : ''} + +
+

+

+ {door.phoneSdk === null ? null : ( +

+ {t('designer:phone.sdkMismatch', 'That phone’s Expo Go is for SDK {phone}, and this app is written for SDK {app}. Install the Expo Go for SDK {app}.', { phone: door.phoneSdk, app: door.sdk ?? '' })} +

+ )} +

+ {t( + 'designer:phone.doorNote', + 'While this is open, the phone that scans the code can open this app’s phone preview and reach what the app makes public, and nothing else on this computer. Only the first phone to scan it is let in. It closes when you stop it, and after half an hour with no phone using it. If nothing happens on the phone, check that both are on the same network and that this computer’s firewall lets it in.', + )} +

+ + ) : door.state === 'starting' ? ( +

+

+ ) : ( + <> +

+ {t( + 'designer:phone.doorLead', + 'This shows a code. A phone with Expo Go, on the same network as this computer, scans it and runs the real app with this computer’s data. Nothing is sent anywhere else.', + )} +

+ {door.state !== 'closed' || door.why === 'stopped' ? null : door.why === 'failed' ? ( + // Why it could not start, in the engine's own words (Expo's last lines among them). +

+ {door.words} +

+ ) : ( +

+ {door.why === 'idle' + ? t('designer:phone.closedIdle', 'It closed after half an hour with no phone using it.') + : t('designer:phone.closedNetwork', 'This computer’s network changed, so the code led nowhere. Show a new one.')} +

+ )} + {addresses.length === 0 ? ( +

+ {t('designer:phone.noNetwork', 'This computer is on no network a phone could reach. Connect it to Wi-Fi first.')} +

+ ) : addresses.length > 1 ? ( + + ) : null} + + )} + {error === null ? null : ( +

+ {error} +

+ )} +
+ + {door.state === 'open' || door.state === 'starting' ? ( + + ) : ( + + )} + +
+ ); +} + +/** + * The Phone side's body. `frame` is the preview's own frame element (made by + * the preview, so a reload and a ticket work as for any side); `over` is what + * the preview draws over a frame (still opening, building, an error). + */ +export function PhonePanel({ preview, frame, over, onFix, compact }: { preview: PreviewModel; frame: ReactNode; over: ReactNode; onFix: (message: string) => void; compact: boolean }): ReactNode { + const [dialog, setDialog] = useState(false); + const status = preview.phone; + if (status === null) return null; + const built = status.phone.state === 'built'; + return ( +
+ {built ? ( + <> +
+
+ {frame} + {over} +
+
+
+ + {t('designer:phone.webNote', 'Drawn by the browser: close to the phone, not the phone. The Designer does not look at this side.')} +
+ + ) : ( + + )} + {dialog ? ( + + ) : null} +
+ ); +} diff --git a/apps/dashboard/src/designer/build/Preview.tsx b/apps/dashboard/src/designer/build/Preview.tsx index 4498d26fa..9c4bb3c57 100644 --- a/apps/dashboard/src/designer/build/Preview.tsx +++ b/apps/dashboard/src/designer/build/Preview.tsx @@ -21,6 +21,7 @@ import { Hammer, LoaderCircle, WandSparkles } from 'lucide-react'; import { AppFrame } from '../../apps/AppFrame.js'; import { t } from '../../i18n/t.js'; import { designerApi, type DesignerSession } from '../api.js'; +import { PhonePanel } from './PhonePanel.js'; import { seesPage, sightFrom } from './sight.js'; import type { TurnView } from './turns.js'; import type { PreviewModel, PreviewSide } from './usePreview.js'; @@ -36,6 +37,8 @@ function building(side: PreviewSide): string { return t('designer:preview.buildingStaff', 'Building the staff side…'); case 'customer': return t('designer:preview.buildingCustomer', 'Building the customer side…'); + case 'mobile': + return t('designer:preview.buildingMobile', 'Building the phone app…'); } } @@ -47,20 +50,26 @@ function failedTitle(side: PreviewSide): string { return t('designer:preview.failedStaff', 'The staff side did not build.'); case 'customer': return t('designer:preview.failedCustomer', 'The customer side did not build.'); + case 'mobile': + return t('designer:preview.failedMobile', 'The phone app did not build.'); } } function crashedTitle(side: PreviewSide): string { return side === 'staff' ? t('designer:preview.crashedStaff', 'The staff screen stopped with an error.') - : t('designer:preview.crashedCustomer', 'The customer screen stopped with an error.'); + : side === 'mobile' + ? t('designer:preview.crashedMobile', 'The phone app stopped with an error.') + : t('designer:preview.crashedCustomer', 'The customer screen stopped with an error.'); } /** The screen went on, and something in it threw: a part of it is empty or wrong, and says nothing. */ function wentOnTitle(side: PreviewSide): string { return side === 'staff' ? t('designer:preview.wentOnStaff', 'The staff screen hit an error and went on. A part of it may be empty.') - : t('designer:preview.wentOnCustomer', 'The customer screen hit an error and went on. A part of it may be empty.'); + : side === 'mobile' + ? t('designer:preview.wentOnMobile', 'The phone app hit an error and went on. A part of it may be empty.') + : t('designer:preview.wentOnCustomer', 'The customer screen hit an error and went on. A part of it may be empty.'); } function refusedTitle(side: PreviewSide): string { @@ -130,7 +139,7 @@ export function Preview({ preview, session, turns, onFix, compact }: { preview: return; } if (data?.type !== 'adminium:side-error' || data.app !== session.appKey) return; - if (data.side !== 'staff' && data.side !== 'customer') return; + if (data.side !== 'staff' && data.side !== 'customer' && data.side !== 'mobile') return; setCrashed({ side: data.side, message: typeof data.message === 'string' ? data.message.slice(0, 600) : '', round, refused: data.refused === true, went: data.went === true }); }; window.addEventListener('message', heard); @@ -159,6 +168,9 @@ export function Preview({ preview, session, turns, onFix, compact }: { preview: /> ); + // A phone app has a frame only once it is built: until then its panel says how it stands, and nothing is "opening". + const phoneShows = side !== 'mobile' || preview.phone?.phone.state === 'built'; + // One element tree for every size, so a size change restyles the frame and never reloads it // (a reload would spend a new ticket, and the old one is already used). // The page sits in a box; the tablet and the phone stand on a dotted ground. @@ -201,11 +213,14 @@ export function Preview({ preview, session, turns, onFix, compact }: { preview:

{t('designer:preview.unavailable', 'The preview could not be opened: {message}', { message: preview.ticketError })}

+ ) : side === 'mobile' ? ( + // The phone app: its own frame and its own states (not added, packages missing, did not build), and the code a phone scans. + ) : ( sized )} - {app !== null && side !== 'staff' && !state.building && state.failed === null && (frameMark === null || loadedFor !== frameMark) && preview.ticketError === null ? ( + {app !== null && side !== 'staff' && phoneShows && !state.building && state.failed === null && (frameMark === null || loadedFor !== frameMark) && preview.ticketError === null ? (
diff --git a/apps/dashboard/src/designer/build/WorkBar.tsx b/apps/dashboard/src/designer/build/WorkBar.tsx index ee476cb24..ed91f486b 100644 --- a/apps/dashboard/src/designer/build/WorkBar.tsx +++ b/apps/dashboard/src/designer/build/WorkBar.tsx @@ -31,11 +31,13 @@ export function sideName(side: PreviewSide): string { return t('designer:preview.staff', 'Staff'); case 'customer': return t('designer:preview.customer', 'Customer'); + case 'mobile': + return t('designer:preview.mobile', 'Phone app'); } } function sideIcon(side: PreviewSide, size = 'size-3.5'): ReactNode { - const Icon = side === 'dashboard' ? LayoutDashboard : side === 'staff' ? IdCard : UserRound; + const Icon = side === 'dashboard' ? LayoutDashboard : side === 'staff' ? IdCard : side === 'mobile' ? Smartphone : UserRound; return