diff --git a/.env.example b/.env.example index 37d2f0ce..2bc39ea5 100644 --- a/.env.example +++ b/.env.example @@ -29,8 +29,9 @@ ENCRYPTION_KEY="ZGV2LW9ubHktZW5jcnlwdGlvbi1rZXktMzItYnl0ZXM=" # PLAID_SECRET="" # PLAID_ENV="sandbox" -# An OpenRouter key, for the model the home page picks and fills in its widgets with. Without it the -# home page says it is not set up. +# The home page's widgets: a TypeSafe AI key for Jev, which picks the widget, and an OpenRouter key +# for the model that fills it in. Without both the home page says it is not set up. +# TYPESAFE_API_KEY="" # OPENROUTER_KEY="" # Who can create an account: addresses and domains, comma-separated. Unset, anyone can. diff --git a/docker-compose.yml b/docker-compose.yml index 825e439b..4a6345b3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,6 +40,7 @@ services: PLAID_ENV: ${PLAID_ENV:-} RESEND_KEY: ${RESEND_KEY:-} EMAIL_FROM: ${EMAIL_FROM:-} + TYPESAFE_API_KEY: ${TYPESAFE_API_KEY:-} OPENROUTER_KEY: ${OPENROUTER_KEY:-} TELEMETRY_ENABLED: ${TELEMETRY_ENABLED:-} logging: *logging diff --git a/packages/api/src/intent/classify.test.ts b/packages/api/src/intent/classify.test.ts index a4655895..9c6196d1 100644 --- a/packages/api/src/intent/classify.test.ts +++ b/packages/api/src/intent/classify.test.ts @@ -1,6 +1,6 @@ import {beforeEach, describe, expect, mock, spyOn, test} from 'bun:test'; -const mockEnv: {OPENROUTER_KEY?: string} = {}; +const mockEnv: {TYPESAFE_API_KEY?: string; OPENROUTER_KEY?: string} = {}; mock.module('../utils/env', () => ({env: mockEnv})); const text = 'Create a calendar invite with Jon Burke and Jacky Doe at One Boulevard Plaza at 7pm tomorrow'; @@ -9,58 +9,70 @@ const text = 'Create a calendar invite with Jon Burke and Jacky Doe at One Boule const NOBODY = {experiments: []}; const WITH_BANKS = {experiments: ['plaid' as const]}; -// What OpenRouter answers: the model's JSON as the message's text. +// What Jev picks, and what OpenRouter answers: the model's JSON as the message's text. +let choice = ''; let content = ''; -let status = 200; -const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation((async () => - status === 200 ? Response.json({choices: [{message: {content}}], provider: 'Cerebras'}) : Response.json({error: {message: 'down'}}, {status})) as unknown as typeof fetch); +let jevStatus = 200; +let openRouterStatus = 200; +const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation((async (input: string) => { + if (input.startsWith('https://api.typesafe.ai/')) { + return jevStatus === 200 + ? Response.json({model: 'jev-1.13.0', answers: {widget: {type: 'choice', choice, confidence: 0.9, probabilities: {[choice]: 0.9}}}, usage: {input_tokens: 1, output_tokens: 1}}) + : Response.json({error: 'down'}, {status: jevStatus}); + } + return openRouterStatus === 200 ? Response.json({choices: [{message: {content}}], provider: 'Cerebras'}) : Response.json({error: {message: 'down'}}, {status: openRouterStatus}); +}) as unknown as typeof fetch); + +function pick(widget: string) { + choice = widget; +} function answer(values: Record) { content = JSON.stringify(values); } -// The body of the request made to OpenRouter. +// The calls made to `host`, each with its headers and body. +function calls(host: string) { + return fetchSpy.mock.calls.filter(([url]) => String(url).startsWith(host)).map(([, init]) => ({headers: init?.headers as Record, body: JSON.parse(String(init?.body))})); +} + +// The body of the request last made to Jev. +function asked(): {model: string; state: string; questions: {widget: {type: string; instructions: string; criteria: Record}}} { + return calls('https://api.typesafe.ai/').at(-1)?.body; +} + +// The body of the request last made to OpenRouter. function sent(): { model: string; messages: Array<{role: string; content: string}>; provider: {order: string[]; data_collection: string}; - response_format: {json_schema: {schema: {required: string[]; properties: {widget: {enum: string[]}}}}}; + response_format: {json_schema: {schema: {required: string[]; properties: Record}}}; } { - return JSON.parse(String(fetchSpy.mock.calls.at(-1)?.[1]?.body)); + return calls('https://openrouter.ai/').at(-1)?.body; } beforeEach(() => { fetchSpy.mockClear(); + mockEnv.TYPESAFE_API_KEY = 'ts-1'; mockEnv.OPENROUTER_KEY = 'sk-or-1'; - status = 200; - answer({ - widget: 'new_event', - attendees: ['Jon Burke', ' Jacky Doe ', 'Jon Burke'], - location: 'One Boulevard Plaza', - title: null, - person: null, - to: [], - subject: null, - from: null, - about: null, - name: null, - owner: null, - }); + jevStatus = 200; + openRouterStatus = 200; + pick('new_event'); + answer({attendees: ['Jon Burke', ' Jacky Doe ', 'Jon Burke'], location: 'One Boulevard Plaza', title: null}); }); -describe('classifyIntent', () => { - test('asks gpt-oss-120b, on Cerebras first, for the widget and every field in one answer', async () => { - const {classifyIntent} = await import('./classify'); - await classifyIntent(text, NOBODY); - - const body = sent(); - expect(body.model).toBe('openai/gpt-oss-120b'); - expect(body.provider.order).toEqual(['Cerebras', 'Groq']); - expect(body.provider.data_collection).toBe('deny'); - expect(body.messages[1]).toEqual({role: 'user', content: text}); - expect(body.messages[0]?.content).toContain('- find_file: Find or open a file'); - const schema = body.response_format.json_schema.schema; - expect(schema.properties.widget.enum).toEqual([ +describe('pickIntent', () => { + test('asks Jev which widget fits, among those offered, incomplete and none, and fills nothing in', async () => { + const {pickIntent} = await import('./classify'); + expect((await pickIntent(text, NOBODY)).unwrap()).toMatchObject({widget: 'new_event'}); + + const [jev] = calls('https://api.typesafe.ai/'); + expect(jev?.headers.authorization).toBe('Bearer ts-1'); + const body = asked(); + expect(body.model).toBe('jev-1.13.0'); + expect(body.state).toBe(text); + expect(body.questions.widget.type).toBe('choice'); + expect(Object.keys(body.questions.widget.criteria)).toEqual([ 'new_event', 'change_event', 'show_calendar', @@ -73,42 +85,65 @@ describe('classifyIntent', () => { 'incomplete', 'none', ]); - expect(schema.required).toEqual([ - 'widget', - 'attendees', - 'location', - 'title', - 'changeWith', - 'changeEvent', - 'changeFrom', - 'changeTo', - 'invite', - 'uninvite', - 'newTitle', - 'newLocation', - 'meetingWith', - 'eventName', - 'person', - 'to', - 'subject', - 'body', - 'from', - 'about', - 'name', - 'owner', - 'expression', - 'amount', - 'fromUnit', - 'toUnit', - ]); + expect(body.questions.widget.criteria.find_file).toBe('Find or open a file, document, spreadsheet or presentation in Drive'); + expect(body.questions.widget.criteria.incomplete).toContain('cut off'); + expect(calls('https://openrouter.ai/')).toHaveLength(0); + }); + + test('offers the bank widgets only to an organization with the plaid experiment', async () => { + const {pickIntent} = await import('./classify'); + pick('find_transactions'); + + expect((await pickIntent('uber charges this month', WITH_BANKS)).unwrap()).toMatchObject({widget: 'find_transactions'}); + expect(asked().questions.widget.criteria.find_transactions).toContain('Find bank or card transactions'); + expect(asked().questions.widget.criteria).toHaveProperty('show_balances'); + + expect((await pickIntent('uber charges this month', NOBODY)).unwrap()).toMatchObject({widget: 'none'}); + expect(asked().questions.widget.criteria).not.toHaveProperty('find_transactions'); + expect(asked().questions.widget.criteria).not.toHaveProperty('show_balances'); + }); + + test('answers incomplete and none as Jev picks them, and a widget there is no such of as none', async () => { + const {pickIntent} = await import('./classify'); + for (const [picked, widget] of [ + ['incomplete', 'incomplete'], + ['none', 'none'], + ['make_coffee', 'none'], + ]) { + pick(picked ?? ''); + expect((await pickIntent('hi', NOBODY)).unwrap()).toMatchObject({widget}); + } + }); + + test('Jev failing is a provider out of reach', async () => { + const {pickIntent} = await import('./classify'); + jevStatus = 503; + expect((await pickIntent(text, NOBODY)).unwrapErr().kind).toBe('provider_unreachable'); + }); +}); + +describe('fillIntent', () => { + test("asks gpt-oss-120b, on Cerebras first, for the widget's fields alone", async () => { + const {fillIntent} = await import('./classify'); + await fillIntent(text, 'new_event', NOBODY); + + const body = sent(); + expect(body.model).toBe('openai/gpt-oss-120b'); + expect(body.provider.order).toEqual(['Cerebras', 'Groq']); + expect(body.provider.data_collection).toBe('deny'); + expect(body.messages[1]).toEqual({role: 'user', content: text}); + expect(body.messages[0]?.content).toContain('Fill in the fields of the New event widget'); + expect(body.messages[0]?.content).toContain('- attendees (list): each person to invite'); + expect(body.messages[0]?.content).not.toContain('find_file'); + expect(body.response_format.json_schema.schema.required).toEqual(['attendees', 'location', 'title']); + expect(calls('https://api.typesafe.ai/')).toHaveLength(0); }); - test("answers the picked widget's fields alone, each value trimmed and once", async () => { - const {classifyIntent} = await import('./classify'); - const intent = (await classifyIntent(text, NOBODY)).unwrap(); + test("answers the widget's fields, each value trimmed and once", async () => { + const {fillIntent} = await import('./classify'); + const filled = (await fillIntent(text, 'new_event', NOBODY)).unwrap(); - expect(intent.widget).toBe('new_event'); - expect(intent.fields).toEqual([ + expect(filled.fields).toEqual([ {id: 'attendees', name: 'Attendees', values: ['Jon Burke', 'Jacky Doe']}, {id: 'location', name: 'Location', values: ['One Boulevard Plaza']}, {id: 'title', name: 'Title', values: []}, @@ -116,99 +151,67 @@ describe('classifyIntent', () => { }); test('answers search terms as each one\'s words joined by " | "', async () => { - answer({widget: 'find_email', from: null, about: [['flight', ' itinerary ', 'e|ticket'], ['SFO'], []]}); - const {classifyIntent} = await import('./classify'); - const intent = (await classifyIntent('find the email about my flight to sfo', NOBODY)).unwrap(); + answer({from: null, about: [['flight', ' itinerary ', 'e|ticket'], ['SFO'], []]}); + const {fillIntent} = await import('./classify'); + const filled = (await fillIntent('find the email about my flight to sfo', 'find_email', NOBODY)).unwrap(); - expect(intent.fields).toEqual([ + expect(filled.fields).toEqual([ {id: 'from', name: 'From', values: []}, {id: 'about', name: 'About', values: ['flight | itinerary | e ticket', 'SFO']}, ]); - const schema = sent().response_format.json_schema.schema as unknown as {properties: Record}; - expect(schema.properties.about).toEqual({type: 'array', items: {type: 'array', items: {type: 'string'}}}); + expect(sent().response_format.json_schema.schema.properties.about).toEqual({type: 'array', items: {type: 'array', items: {type: 'string'}}}); }); - test('offers the bank widgets, and their fields, only to an organization with the plaid experiment', async () => { - const {classifyIntent} = await import('./classify'); - await classifyIntent('how much did I spend on restaurants last month', WITH_BANKS); - - const withBanks = sent(); - expect(withBanks.messages[0]?.content).toContain('- find_transactions: Find bank or card transactions'); - expect(withBanks.messages[0]?.content).toContain('FOOD_AND_DRINK (restaurants, groceries'); - expect(withBanks.response_format.json_schema.schema.properties.widget.enum).toContain('show_balances'); - expect(withBanks.response_format.json_schema.schema.required).toEqual(expect.arrayContaining(['balanceAccount', 'merchant', 'category', 'flow', 'transactionAccount'])); - - await classifyIntent('how much did I spend on restaurants last month', NOBODY); - const without = sent(); - expect(without.messages[0]?.content).not.toContain('find_transactions'); - expect(without.response_format.json_schema.schema.properties.widget.enum).not.toContain('show_balances'); - expect(without.response_format.json_schema.schema.required).not.toContain('merchant'); - }); - - test('answers a bank widget to an organization with the experiment, and none to one without', async () => { - const {classifyIntent} = await import('./classify'); - answer({widget: 'find_transactions', merchant: 'uber', category: 'TRANSPORTATION', flow: 'out', transactionAccount: null}); - - expect((await classifyIntent('uber charges this month', WITH_BANKS)).unwrap()).toMatchObject({ - widget: 'find_transactions', - fields: [ - {id: 'merchant', values: ['uber']}, - {id: 'category', values: ['TRANSPORTATION']}, - {id: 'flow', values: ['out']}, - {id: 'transactionAccount', values: []}, - ], - }); - expect((await classifyIntent('uber charges this month', NOBODY)).unwrap()).toMatchObject({widget: 'none', fields: []}); - }); - - test("every field id is one widget's alone, as the model answers them all in one object", async () => { - const {WIDGETS} = await import('./widgets'); - const ids = WIDGETS.flatMap((widget) => widget.fields.map((field) => field.id)); - expect(new Set(ids).size).toBe(ids.length); - }); - - test('holds a unit to the units there are', async () => { - const {classifyIntent} = await import('./classify'); - await classifyIntent('5 miles in km', NOBODY); + test('fills a bank widget in for an organization with the experiment, and none for one without', async () => { + const {fillIntent} = await import('./classify'); + answer({merchant: 'uber', category: 'TRANSPORTATION', flow: 'out', transactionAccount: null}); - const schema = sent().response_format.json_schema.schema as unknown as {properties: Record}>}; - expect(schema.properties.fromUnit?.enum).toContain('mi'); - expect(schema.properties.toUnit?.enum).toContain(null); - }); + expect((await fillIntent('uber charges this month', 'find_transactions', WITH_BANKS)).unwrap().fields).toMatchObject([ + {id: 'merchant', values: ['uber']}, + {id: 'category', values: ['TRANSPORTATION']}, + {id: 'flow', values: ['out']}, + {id: 'transactionAccount', values: []}, + ]); + expect(sent().messages[0]?.content).toContain('FOOD_AND_DRINK (restaurants, groceries'); - test('none, or a widget there is no such of, has no fields', async () => { - const {classifyIntent} = await import('./classify'); - for (const widget of ['none', 'make_coffee']) { - answer({widget}); - expect((await classifyIntent('hi', NOBODY)).unwrap()).toMatchObject({widget: 'none', fields: []}); + fetchSpy.mockClear(); + for (const widget of ['find_transactions', 'none', 'make_coffee']) { + expect((await fillIntent('uber charges this month', widget, NOBODY)).unwrapErr()).toMatchObject({kind: 'not_found', resource: 'widget', id: widget}); } + expect(fetchSpy).not.toHaveBeenCalled(); }); - test('tells text still being typed from a whole request no widget does', async () => { - const {classifyIntent} = await import('./classify'); - answer({widget: 'incomplete'}); - - expect((await classifyIntent('find the', NOBODY)).unwrap()).toMatchObject({widget: 'incomplete', fields: []}); - expect(sent().messages[0]?.content).toContain('- incomplete: the text is cut off'); - }); - - test('is unavailable without a key, and asks nothing', async () => { - delete mockEnv.OPENROUTER_KEY; - const {classifyIntent} = await import('./classify'); + test('holds a unit to the units there are', async () => { + answer({amount: '5', fromUnit: 'mi', toUnit: 'km'}); + const {fillIntent} = await import('./classify'); + await fillIntent('5 miles in km', 'convert_units', NOBODY); - expect((await classifyIntent(text, NOBODY)).unwrapErr()).toMatchObject({kind: 'unavailable', message: 'The home page is not set up: set `OPENROUTER_KEY`.'}); - expect(fetchSpy).not.toHaveBeenCalled(); + const properties = sent().response_format.json_schema.schema.properties as Record}>; + expect(properties.fromUnit?.enum).toContain('mi'); + expect(properties.toUnit?.enum).toContain(null); }); test('OpenRouter failing, or the model answering outside the form, is a provider out of reach', async () => { - const {classifyIntent} = await import('./classify'); + const {fillIntent} = await import('./classify'); - status = 503; - expect((await classifyIntent(text, NOBODY)).unwrapErr().kind).toBe('provider_unreachable'); - status = 200; + openRouterStatus = 503; + expect((await fillIntent(text, 'new_event', NOBODY)).unwrapErr().kind).toBe('provider_unreachable'); + openRouterStatus = 200; content = 'Sure! Here is the form:'; - expect((await classifyIntent(text, NOBODY)).unwrapErr().kind).toBe('provider_unreachable'); - answer({attendees: ['Jon']}); - expect((await classifyIntent(text, NOBODY)).unwrapErr().kind).toBe('provider_unreachable'); + expect((await fillIntent(text, 'new_event', NOBODY)).unwrapErr().kind).toBe('provider_unreachable'); }); }); + +test('picking and filling in are unavailable without either key, and ask nothing', async () => { + const {fillIntent, pickIntent} = await import('./classify'); + + for (const key of ['TYPESAFE_API_KEY', 'OPENROUTER_KEY'] as const) { + mockEnv.TYPESAFE_API_KEY = 'ts-1'; + mockEnv.OPENROUTER_KEY = 'sk-or-1'; + delete mockEnv[key]; + for (const result of [await pickIntent(text, NOBODY), await fillIntent(text, 'new_event', NOBODY)]) { + expect(result.unwrapErr()).toMatchObject({kind: 'unavailable', message: 'The home page is not set up: set `TYPESAFE_API_KEY` and `OPENROUTER_KEY`.'}); + } + } + expect(fetchSpy).not.toHaveBeenCalled(); +}); diff --git a/packages/api/src/intent/classify.ts b/packages/api/src/intent/classify.ts index 6134eb63..f1b81672 100644 --- a/packages/api/src/intent/classify.ts +++ b/packages/api/src/intent/classify.ts @@ -1,25 +1,31 @@ import {Err, Ok, Result} from 'ts-results-es'; import {z} from 'zod'; -import {ApiErr, type ApiError, type Experiment, httpRequest, parseSchema} from '@proxy/utils'; +import {ApiErr, type ApiError, Do, type Experiment, httpRequest, parseSchema} from '@proxy/utils'; import {env} from '../utils/env'; import {INCOMPLETE, NO_WIDGET, offeredWidgets, type Widget, type WidgetField} from './widgets'; /** - * What a person typed into the home page's input, in one request to gpt-oss-120b over OpenRouter, - * on Cerebras, the fastest host, else Groq: the widget that fits, and its fields filled in from the - * text, in about 150 ms. Picked over Jev and GLiNER, one after the other, by trying them on typed - * requests: as fast, and every field right on nine in ten where GLiNER managed half, since it - * found no title it wasn't told of, no subject and no file's name. The answer is held to a JSON - * schema: the widget's id, and every widget's fields, the others left empty. The widgets offered - * are the organization's: a widget behind an experiment it doesn't have is in neither the - * instructions nor the schema. + * What a person typed into the home page's input, in two steps, each a request of its own so the + * home page shows the widget as soon as it is picked and fills it in when its fields come. First + * Jev, TypeSafe AI's classifier, picks the widget that fits: one choice among the widgets offered, + * `incomplete` and `none`. Then gpt-oss-120b, over OpenRouter on Cerebras, the fastest host, else + * Groq, fills in that widget's fields from the text, held to a JSON schema of them alone. It fills + * fields better than GLiNER did, every one right on nine in ten typed requests where GLiNER managed + * half, since GLiNER found no title it wasn't told of, no subject and no file's name. The widgets + * offered are the organization's: a widget behind an experiment it doesn't have is not among Jev's + * choices, nor filled in. */ +const TYPESAFE_URL = 'https://api.typesafe.ai/v1/systemone'; + +// Pinned, so a new release of Jev doesn't change which widget is picked until it is moved to. +const JEV_MODEL = 'jev-1.13.0'; + const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'; -const MODEL = 'openai/gpt-oss-120b'; +const FILL_MODEL = 'openai/gpt-oss-120b'; // While typing goes on, a newer text is asked about every 300 ms, so a slower answer is never shown. const TIMEOUT_MS = 5000; @@ -29,22 +35,42 @@ const MAX_TOKENS = 2000; /** A field of the picked widget and what fills it: one value, or each of a list's. */ export type FilledField = {id: string; name: string; values: string[]}; -export type Intent = { +/** The widget Jev picked. */ +export type Picked = { /** The widget's id, `none` when what was asked is whole and none does it, or `incomplete` when too little was typed to tell yet. */ widget: string; - /** The widget's fields, each with what fills it; none for `none` and `incomplete`. */ + /** How long Jev took. */ + ms: number; +}; + +/** The picked widget's fields, filled in. */ +export type Filled = { fields: FilledField[]; /** How long the model took. */ ms: number; }; -function instructions(widgets: Widget[]): string { +// Jev's question: which of the widgets, `incomplete` or `none`, each described for it to tell apart. +function widgetQuestion(widgets: Widget[]) { + return { + type: 'choice', + instructions: 'Which quick action is the person asking for in what they typed into a command box?', + criteria: { + ...Object.fromEntries(widgets.map((widget) => [widget.id, widget.description])), + [INCOMPLETE]: + 'The text is cut off or says too little to tell what is wanted yet, as it is while still being typed ("find the", "email", "what\'s"); never a whole sentence or question, which ends in a question mark or a full stop', + [NO_WIDGET]: 'A whole request that none of the other actions does ("summarize my week", "what\'s the weather tomorrow")', + }, + }; +} + +const pickSchema = z.object({answers: z.object({widget: z.object({choice: z.string()})})}); + +function instructions(widget: Widget): string { return [ - 'Pick the widget for what the person typed into a command box, and fill in its fields from the text. Copy values from the text, but write the body of an email; never invent a person, place or address. Leave the fields of other widgets empty. Answer with JSON only.', - 'Widgets:', - ...widgets.map((widget) => `- ${widget.id}: ${widget.description}. Fields: ${widget.fields.map((field) => `${field.id}${field.many ? ' (list)' : ''}: ${field.hint}`).join('; ')}`), - `- ${INCOMPLETE}: the text is cut off or says too little to tell what is wanted yet, as it is while still being typed ("find the", "email", "what's"); never a whole sentence or question, which ends in a question mark or a full stop`, - `- ${NO_WIDGET}: a whole request that nothing above does ("summarize my week", "what's the weather tomorrow")`, + `Fill in the fields of the ${widget.name} widget (${widget.description}) from what the person typed into a command box. Copy values from the text, but write the body of an email; never invent a person, place or address. Answer with JSON only.`, + 'Fields:', + ...widget.fields.map((field) => `- ${field.id}${field.many ? ' (list)' : ''}: ${field.hint}`), ].join('\n'); } @@ -61,40 +87,19 @@ function fieldSchema(field: WidgetField) { return {type: ['string', 'null']}; } -function jsonSchema(widgets: Widget[]) { - const fields = widgets.flatMap((widget) => widget.fields); +function jsonSchema(widget: Widget) { return { type: 'object', additionalProperties: false, - required: ['widget', ...fields.map((field) => field.id)], - properties: { - widget: {type: 'string', enum: [...widgets.map((widget) => widget.id), INCOMPLETE, NO_WIDGET]}, - ...Object.fromEntries(fields.map((field) => [field.id, fieldSchema(field)])), - }, + required: widget.fields.map((field) => field.id), + properties: Object.fromEntries(widget.fields.map((field) => [field.id, fieldSchema(field)])), }; } -type Form = {widgets: Widget[]; instructions: string; schema: ReturnType}; - -// The instructions and schema for each set of widgets offered, built once: there are few sets. -const forms = new Map(); - -function formFor(holder: {experiments: Experiment[]}): Form { - const widgets = offeredWidgets(holder); - const key = widgets.map((widget) => widget.id).join(' '); - const kept = forms.get(key); - if (kept) { - return kept; - } - const form = {widgets, instructions: instructions(widgets), schema: jsonSchema(widgets)}; - forms.set(key, form); - return form; -} - const completionSchema = z.object({choices: z.array(z.object({message: z.object({content: z.string()})})).min(1)}); -// The widget's id, and each field's value by its id. -const answerSchema = z.object({widget: z.string()}).catchall(z.union([z.string(), z.array(z.string()), z.array(z.array(z.string())), z.null()])); +// Each field's value by its id. +const answerSchema = z.record(z.string(), z.union([z.string(), z.array(z.string()), z.array(z.array(z.string())), z.null()])); type AnswerValue = z.infer[string]; @@ -114,32 +119,48 @@ function cleaned(value: AnswerValue | undefined): string[] { return unique(value.map((one) => (Array.isArray(one) ? unique(one.map((word) => word.replace(/\|/g, ' '))).join(' | ') : one))); } -/** Which of the widgets offered to `holder` fits `text`, and its fields as the model fills them in. */ -export async function classifyIntent(text: string, holder: {experiments: Experiment[]}): Promise> { - if (!env.OPENROUTER_KEY) { - return Err(ApiErr.unavailable('The home page is not set up: set `OPENROUTER_KEY`.')); +// Which of `widgets` Jev picks for `text`: one of them, `incomplete` or `none`. +async function pickWidget(text: string, widgets: Widget[], key: string): Promise> { + const picked = await httpRequest( + TYPESAFE_URL, + { + method: 'POST', + headers: {'content-type': 'application/json', authorization: `Bearer ${key}`}, + body: JSON.stringify({model: JEV_MODEL, state: text, questions: {widget: widgetQuestion(widgets)}}), + }, + {schema: pickSchema, timeoutMs: TIMEOUT_MS}, + ); + if (picked.isErr()) { + return Err(ApiErr.providerUnreachable(picked.error)); } - const form = formFor(holder); - const start = performance.now(); + const choice = picked.value.answers.widget.choice; + if (choice === INCOMPLETE) { + return Ok(INCOMPLETE); + } + return Ok(widgets.find((widget) => widget.id === choice) ?? NO_WIDGET); +} + +// `widget`'s fields, as gpt-oss-120b fills them in from `text`. +async function fillWidget(text: string, widget: Widget, key: string): Promise> { const answered = await httpRequest( OPENROUTER_URL, { method: 'POST', - headers: {'content-type': 'application/json', authorization: `Bearer ${env.OPENROUTER_KEY}`}, + headers: {'content-type': 'application/json', authorization: `Bearer ${key}`}, body: JSON.stringify({ - model: MODEL, + model: FILL_MODEL, messages: [ - {role: 'system', content: form.instructions}, + {role: 'system', content: instructions(widget)}, {role: 'user', content: text}, ], temperature: 0, - // Its thinking and every widget's fields count toward it, an email's body among them, so - // it is well past what an answer takes: cut off, the answer would be no JSON at all. + // Its thinking and the fields count toward it, an email's body among them, so it is + // well past what an answer takes: cut off, the answer would be no JSON at all. max_tokens: MAX_TOKENS, // It thinks before answering; a little is enough here, and keeps it fast. reasoning: {effort: 'low'}, - response_format: {type: 'json_schema', json_schema: {name: 'intent', strict: true, schema: form.schema}}, + response_format: {type: 'json_schema', json_schema: {name: 'fields', strict: true, schema: jsonSchema(widget)}}, // Cerebras first, Groq next, and any other host of the model after them, but only a // host OpenRouter records as neither keeping prompts nor training on them: the // text is what the person typed, their bank's name or a merchant's among it. @@ -148,7 +169,6 @@ export async function classifyIntent(text: string, holder: {experiments: Experim }, {schema: completionSchema, timeoutMs: TIMEOUT_MS}, ); - const ms = Math.round(performance.now() - start); if (answered.isErr()) { return Err(ApiErr.providerUnreachable(answered.error)); } @@ -158,13 +178,38 @@ export async function classifyIntent(text: string, holder: {experiments: Experim if (answer.isErr()) { return Err(ApiErr.providerUnreachable(new Error(`The model answered outside the form: ${content.slice(0, 200)}`))); } + return Ok(widget.fields.map((field) => ({id: field.id, name: field.name, values: cleaned(answer.value[field.id])}))); +} - if (answer.value.widget === INCOMPLETE) { - return Ok({widget: INCOMPLETE, fields: [], ms}); +// Both keys, as the home page needs Jev and the model that fills in alike. +function keys(): Result<{typesafe: string; openRouter: string}, ApiError> { + if (!env.TYPESAFE_API_KEY || !env.OPENROUTER_KEY) { + return Err(ApiErr.unavailable('The home page is not set up: set `TYPESAFE_API_KEY` and `OPENROUTER_KEY`.')); } - const widget = form.widgets.find((candidate) => candidate.id === answer.value.widget); - if (!widget) { - return Ok({widget: NO_WIDGET, fields: [], ms}); - } - return Ok({widget: widget.id, fields: widget.fields.map((field) => ({id: field.id, name: field.name, values: cleaned(answer.value[field.id])})), ms}); + return Ok({typesafe: env.TYPESAFE_API_KEY, openRouter: env.OPENROUTER_KEY}); +} + +/** Which of the widgets offered to `holder` fits `text`, as Jev picks it. */ +export function pickIntent(text: string, holder: {experiments: Experiment[]}): Promise> { + return Do(async ($) => { + const {typesafe} = $(keys()); + const start = performance.now(); + const picked = $(await pickWidget(text, offeredWidgets(holder), typesafe)); + return {widget: typeof picked === 'string' ? picked : picked.id, ms: Math.round(performance.now() - start)}; + }); +} + +/** The fields of `widgetId`, one of the widgets offered to `holder`, as gpt-oss-120b fills them in from `text`. */ +export function fillIntent(text: string, widgetId: string, holder: {experiments: Experiment[]}): Promise> { + return Do(async ($) => { + const {openRouter} = $(keys()); + const widget = offeredWidgets(holder).find((candidate) => candidate.id === widgetId); + if (!widget) { + return $(Err(ApiErr.notFound('widget', widgetId))); + } + + const start = performance.now(); + const fields = $(await fillWidget(text, widget, openRouter)); + return {fields, ms: Math.round(performance.now() - start)}; + }); } diff --git a/packages/api/src/intent/route.test.ts b/packages/api/src/intent/route.test.ts index 56adc545..22128ae9 100644 --- a/packages/api/src/intent/route.test.ts +++ b/packages/api/src/intent/route.test.ts @@ -1,9 +1,10 @@ import {beforeEach, describe, expect, mock, test} from 'bun:test'; import {Ok} from 'ts-results-es'; -const classifyIntent = mock(); +const pickIntent = mock(); +const fillIntent = mock(); -mock.module('./classify', () => ({classifyIntent})); +mock.module('./classify', () => ({pickIntent, fillIntent})); function makeRequest(body: unknown) { return {user: {userId: 'user-1', experiments: ['plaid']}, body} as never; @@ -11,24 +12,44 @@ function makeRequest(body: unknown) { beforeEach(() => { mock.clearAllMocks(); - classifyIntent.mockResolvedValue(Ok({widget: 'none', fields: [], ms: 1})); + pickIntent.mockResolvedValue(Ok({widget: 'none', ms: 1})); + fillIntent.mockResolvedValue(Ok({fields: [], ms: 1})); }); -describe('handleClassifyIntentRoute', () => { - test("classifies the text, trimmed, among the widgets the person's organization is offered", async () => { - const {handleClassifyIntentRoute} = await import('./route'); - const result = await handleClassifyIntentRoute(makeRequest({text: ' who is hugo '})); +describe('handlePickIntentRoute', () => { + test("picks among the widgets the person's organization is offered, for the text trimmed", async () => { + const {handlePickIntentRoute} = await import('./route'); + const result = await handlePickIntentRoute(makeRequest({text: ' who is hugo '})); expect(result.isOk()).toBe(true); - expect(classifyIntent).toHaveBeenCalledWith('who is hugo', {userId: 'user-1', experiments: ['plaid']}); + expect(pickIntent).toHaveBeenCalledWith('who is hugo', {userId: 'user-1', experiments: ['plaid']}); }); test('refuses empty text, and text past 500 characters, without asking the models', async () => { - const {handleClassifyIntentRoute} = await import('./route'); + const {handlePickIntentRoute} = await import('./route'); for (const text of [' ', 'a'.repeat(501)]) { - expect((await handleClassifyIntentRoute(makeRequest({text}))).unwrapErr().kind).toBe('parse_error'); + expect((await handlePickIntentRoute(makeRequest({text}))).unwrapErr().kind).toBe('parse_error'); } - expect(classifyIntent).not.toHaveBeenCalled(); + expect(pickIntent).not.toHaveBeenCalled(); + }); +}); + +describe('handleFillIntentRoute', () => { + test("fills in the widget's fields for the person's organization, from the text trimmed", async () => { + const {handleFillIntentRoute} = await import('./route'); + const result = await handleFillIntentRoute(makeRequest({text: ' who is hugo ', widget: 'find_person'})); + + expect(result.isOk()).toBe(true); + expect(fillIntent).toHaveBeenCalledWith('who is hugo', 'find_person', {userId: 'user-1', experiments: ['plaid']}); + }); + + test('refuses a request without a widget, or with text that is empty or past 500 characters', async () => { + const {handleFillIntentRoute} = await import('./route'); + + for (const body of [{text: 'who is hugo'}, {text: 'who is hugo', widget: ''}, {text: ' ', widget: 'find_person'}, {text: 'a'.repeat(501), widget: 'find_person'}]) { + expect((await handleFillIntentRoute(makeRequest(body))).unwrapErr().kind).toBe('parse_error'); + } + expect(fillIntent).not.toHaveBeenCalled(); }); }); diff --git a/packages/api/src/intent/route.ts b/packages/api/src/intent/route.ts index ccc794b5..04141074 100644 --- a/packages/api/src/intent/route.ts +++ b/packages/api/src/intent/route.ts @@ -4,14 +4,26 @@ import {z} from 'zod'; import {type ApiError, Do, parseSchema} from '@proxy/utils'; import type {AuthenticatedRequest} from '../server/middleware/require-auth'; -import {classifyIntent, type Intent} from './classify'; +import {type Filled, fillIntent, type Picked, pickIntent} from './classify'; -const intentBodySchema = z.object({text: z.string().trim().min(1).max(500)}); +const text = z.string().trim().min(1).max(500); -/** `POST /api/intent` with `{text}` — what the home page's input asks for, as it is typed. */ -export function handleClassifyIntentRoute(request: AuthenticatedRequest): Promise> { +const pickBodySchema = z.object({text}); + +const fillBodySchema = z.object({text, widget: z.string().min(1)}); + +/** `POST /api/intent` with `{text}` — which widget fits what the home page's input asks for, as it is typed. */ +export function handlePickIntentRoute(request: AuthenticatedRequest): Promise> { + return Do(async ($) => { + const body = $(parseSchema(pickBodySchema, request.body)); + return $(await pickIntent(body.text, request.user)); + }); +} + +/** `POST /api/intent/fields` with `{text, widget}` — the fields of the widget picked for `text`, filled in. */ +export function handleFillIntentRoute(request: AuthenticatedRequest): Promise> { return Do(async ($) => { - const {text} = $(parseSchema(intentBodySchema, request.body)); - return $(await classifyIntent(text, request.user)); + const body = $(parseSchema(fillBodySchema, request.body)); + return $(await fillIntent(body.text, body.widget, request.user)); }); } diff --git a/packages/api/src/intent/widgets.ts b/packages/api/src/intent/widgets.ts index f0665455..513088b2 100644 --- a/packages/api/src/intent/widgets.ts +++ b/packages/api/src/intent/widgets.ts @@ -3,11 +3,10 @@ import {TRANSACTION_CATEGORIES} from '@proxy/utils/transaction-categories'; import {UNITS} from '@proxy/utils/units'; /** - * The widgets the home page shows for what a person types, Sana's way: a small, fast LLM picks the - * one that fits and fills in its fields from the text. Most are meant for one kind of data: + * The widgets the home page shows for what a person types, Sana's way: Jev picks the one that fits, + * and a small, fast LLM fills in its fields from the text. Most are meant for one kind of data: * Calendar, Contacts, Gmail or Drive, or a bank; Math and Convert units are worked out in the web - * app. Field ids are unique across widgets, as the LLM answers every widget's fields in one object. - * A widget behind an experiment is offered only to an organization with it on. + * app. A widget behind an experiment is offered only to an organization with it on. */ export type WidgetField = { @@ -33,7 +32,7 @@ export type WidgetField = { export type Widget = { id: string; name: string; - /** What the LLM reads to tell the widgets apart. */ + /** What Jev reads to tell the widgets apart, and the LLM reads to know what it fills in. */ description: string; fields: WidgetField[]; /** The experiment an organization needs on to be offered it, as banks are behind `plaid`. */ diff --git a/packages/api/src/server/create-api-router.ts b/packages/api/src/server/create-api-router.ts index 8a80576c..fe8d6672 100644 --- a/packages/api/src/server/create-api-router.ts +++ b/packages/api/src/server/create-api-router.ts @@ -28,7 +28,7 @@ import {handleNotionCallbackRoute, handleStartNotionRoute} from '../connections/ import {handleConnectPlaidRoute, handleCreatePlaidLinkTokenRoute} from '../connections/plaid/route'; import {handleConnectPostgresRoute, handleTestPostgresRoute} from '../connections/postgres/route'; import {handleDeleteConnectionRoute, handleGetConnectionRoute, handleListConnectionsRoute, handleRenameConnectionRoute, handleSetConnectionDefaultsRoute} from '../connections/route'; -import {handleClassifyIntentRoute} from '../intent/route'; +import {handleFillIntentRoute, handlePickIntentRoute} from '../intent/route'; import {handleMcpMethodNotAllowedRoute, handleMcpRoute} from '../mcp/route'; import {handleDeleteMeRoute, handleFinishOnboardingRoute, handleMeRoute} from '../me/route'; import {handleAuthorizeRoute, handleConsentRoute, handleGetAuthorizationRequestRoute} from '../oauth/authorize'; @@ -119,9 +119,10 @@ export function createApiRouter(): express.Router { router.get('/api/activity', withAuthResult('List activity route', handleListActivityRoute)); router.get('/api/activity/summary', withAuthResult('Activity summary route', handleActivitySummaryRoute)); - // The home page: which widget fits what the person types, and the entities in it. + // The home page: which widget fits what the person types, then its fields filled in. router.use('/api/intent', requireBrowserOrigin); - router.post('/api/intent', withAuthResult('Classify intent route', handleClassifyIntentRoute)); + router.post('/api/intent', withAuthResult('Pick intent route', handlePickIntentRoute)); + router.post('/api/intent/fields', withAuthResult('Fill intent route', handleFillIntentRoute)); // Its widgets: every connection with the collection a widget is about, read at once, and // written to as the person picks. router.use('/api/widgets', requireBrowserOrigin); diff --git a/packages/api/src/utils/env-schema.ts b/packages/api/src/utils/env-schema.ts index aefd62ea..f6b2edee 100644 --- a/packages/api/src/utils/env-schema.ts +++ b/packages/api/src/utils/env-schema.ts @@ -99,10 +99,15 @@ export const envSchema = z.object({ description: 'Which of Plaid’s environments to use: `sandbox` connects only Plaid’s test banks, signed in to with `user_good` and `pass_good`.', }), + TYPESAFE_API_KEY: documented(z.string().min(1).optional(), { + group: 'Core', + description: + 'A TypeSafe AI API key, for Jev, which picks the widget on the home page that fits what a person types. What is typed there is sent to it. Without it, or without `OPENROUTER_KEY`, the home page says it is not set up; everything else works as before.', + }), OPENROUTER_KEY: documented(z.string().min(1).optional(), { group: 'Core', description: - 'An OpenRouter API key, for the model the home page asks which widget fits what a person types and what fills it in (gpt-oss-120b, on Cerebras or Groq). What is typed there is sent to it. Without it the home page says it is not set up; everything else works as before.', + 'An OpenRouter API key, for the model that fills in the fields of the widget Jev picks on the home page (gpt-oss-120b, on Cerebras or Groq). What is typed there is sent to it. Without it, or without `TYPESAFE_API_KEY`, the home page says it is not set up; everything else works as before.', }), NODE_ENV: documented(z.enum(['development', 'test', 'production']).default('production'), { diff --git a/packages/api/src/utils/env.ts b/packages/api/src/utils/env.ts index e02a56f0..dba05cc2 100644 --- a/packages/api/src/utils/env.ts +++ b/packages/api/src/utils/env.ts @@ -22,6 +22,7 @@ export const env = envSchema.parse({ GOOGLE_CLIENT_SECRET: read('GOOGLE_CLIENT_SECRET'), RESEND_KEY: read('RESEND_KEY'), EMAIL_FROM: read('EMAIL_FROM'), + TYPESAFE_API_KEY: read('TYPESAFE_API_KEY'), OPENROUTER_KEY: read('OPENROUTER_KEY'), ALLOWED_SIGNUP_EMAILS: read('ALLOWED_SIGNUP_EMAILS'), PLAID_CLIENT_ID: read('PLAID_CLIENT_ID'), diff --git a/packages/web/src/client/intent-client.ts b/packages/web/src/client/intent-client.ts index 0bf779b8..d643587c 100644 --- a/packages/web/src/client/intent-client.ts +++ b/packages/web/src/client/intent-client.ts @@ -1,18 +1,29 @@ import {z} from 'zod'; import {apiRequest} from '@/client/request'; -const intentSchema = z.object({ +// A widget's fields, each with what fills it: one value, or each of a list's. +const fieldsSchema = z.array( + z.object({id: z.string(), name: z.string(), values: z.array(z.string())}), +); + +const pickedSchema = z.object({ // The widget's id, `none` when what was asked is whole and no widget does it, or `incomplete` // when too little was typed to tell yet. widget: z.string(), - // Its fields, each with what fills it: one value, or each of a list's. - fields: z.array(z.object({id: z.string(), name: z.string(), values: z.array(z.string())})), ms: z.number(), }); -export type Intent = z.infer; +const filledSchema = z.object({fields: fieldsSchema, ms: z.number()}); + +/** A widget and its fields, as the home page shows it. */ +export type Intent = {widget: string; fields: z.infer}; + +/** Which widget fits what was typed (`POST /api/intent`). */ +export function pickIntent(text: string) { + return apiRequest('POST', '/api/intent', pickedSchema, {text}); +} -/** Which widget fits what was typed, and its fields filled in (`POST /api/intent`). */ -export function classifyIntent(text: string) { - return apiRequest('POST', '/api/intent', intentSchema, {text}); +/** The fields of the widget picked for what was typed, filled in (`POST /api/intent/fields`). */ +export function fillIntent(text: string, widget: string) { + return apiRequest('POST', '/api/intent/fields', filledSchema, {text, widget}); } diff --git a/packages/web/src/components/widgets/intent-widget.test.tsx b/packages/web/src/components/widgets/intent-widget.test.tsx index f094e03e..2ceb3342 100644 --- a/packages/web/src/components/widgets/intent-widget.test.tsx +++ b/packages/web/src/components/widgets/intent-widget.test.tsx @@ -32,7 +32,6 @@ function intentWith(widget: string, fields: Record): Intent { return { widget, fields: Object.entries(fields).map(([id, values]) => ({id, name: id, values})), - ms: 0, }; } diff --git a/packages/web/src/components/widgets/intent-widget.tsx b/packages/web/src/components/widgets/intent-widget.tsx index 45edf2ba..51ea779a 100644 --- a/packages/web/src/components/widgets/intent-widget.tsx +++ b/packages/web/src/components/widgets/intent-widget.tsx @@ -1,4 +1,18 @@ +import { + ArrowRightLeftIcon, + CalculatorIcon, + CalendarDaysIcon, + CalendarIcon, + FileSearchIcon, + InboxIcon, + LandmarkIcon, + MailIcon, + ReceiptTextIcon, + UserRoundIcon, + type LucideIcon, +} from 'lucide-react'; import type {Intent} from '@/client/intent-client'; +import {Skeleton} from '@proxy/ui/components/skeleton'; import {BalancesWidget} from '@/components/widgets/balances-widget'; import {CalendarWidget} from '@/components/widgets/calendar-widget'; import {ConvertWidget} from '@/components/widgets/convert-widget'; @@ -9,6 +23,7 @@ import {FindFileWidget} from '@/components/widgets/find-file-widget'; import {MathWidget} from '@/components/widgets/math-widget'; import {PersonWidget} from '@/components/widgets/person-widget'; import {TransactionsWidget} from '@/components/widgets/transactions-widget'; +import {WidgetFrame} from '@/components/widgets/widget-frame'; import {WriteEmailWidget} from '@/components/widgets/write-email-widget'; type Props = { @@ -60,3 +75,36 @@ export function IntentWidget({intent, typed, now, onAsk}: Props) { } return null; } + +// What each widget is called and its icon, as it shows them, for its frame while its fields are +// filled in. +const FRAMES: Partial> = { + new_event: {icon: CalendarIcon, name: 'New event'}, + change_event: {icon: CalendarIcon, name: 'Change event'}, + show_calendar: {icon: CalendarDaysIcon, name: 'Calendar'}, + find_person: {icon: UserRoundIcon, name: 'Person'}, + write_email: {icon: MailIcon, name: 'Write email'}, + find_email: {icon: InboxIcon, name: 'Find email'}, + find_file: {icon: FileSearchIcon, name: 'Find file'}, + calculate: {icon: CalculatorIcon, name: 'Math'}, + convert_units: {icon: ArrowRightLeftIcon, name: 'Convert units'}, + show_balances: {icon: LandmarkIcon, name: 'Balances'}, + find_transactions: {icon: ReceiptTextIcon, name: 'Transactions'}, +}; + +// The widget the api picked, as soon as it is picked: its frame, until its fields are filled in. +export function FillingWidget({widget}: {widget: string}) { + const frame = FRAMES[widget]; + if (!frame) { + return null; + } + return ( + +

Filling in…

+
+ + +
+
+ ); +} diff --git a/packages/web/src/lib/widget-values.test.ts b/packages/web/src/lib/widget-values.test.ts index 4301b58f..800dea35 100644 --- a/packages/web/src/lib/widget-values.test.ts +++ b/packages/web/src/lib/widget-values.test.ts @@ -28,7 +28,6 @@ function intentWith(widget: string, fields: Record): Intent { return { widget, fields: Object.entries(fields).map(([id, values]) => ({id, name: id, values})), - ms: 0, }; } diff --git a/packages/web/src/pages/home-page.test.tsx b/packages/web/src/pages/home-page.test.tsx index 10808226..20c75d0e 100644 --- a/packages/web/src/pages/home-page.test.tsx +++ b/packages/web/src/pages/home-page.test.tsx @@ -90,14 +90,28 @@ let home: { }; const realFetch = globalThis.fetch; -// `/api/intent`, which each test answers; the widgets' own requests go to a fake of their api. +// `/api/intent`, the widget picked, which each test answers; `/api/intent/fields`, its fields, filled +// in as `intent` and `person` have them unless a test says otherwise; the widgets' own requests go +// to a fake of their api. const fetchMock = mock<(input: string, init?: RequestInit) => Promise>(); +const fieldsMock = mock<(input: string, init?: RequestInit) => Promise>(); + +function fieldsFor(init?: RequestInit): Promise { + const {widget} = JSON.parse(String(init?.body)) as {widget: string}; + const filled = [intent, person].find((one) => one.widget === widget); + return Promise.resolve(Response.json({fields: filled?.fields ?? [], ms: 1})); +} beforeEach(() => { fetchMock.mockReset(); + fieldsMock.mockReset(); + fieldsMock.mockImplementation((_input, init) => fieldsFor(init)); globalThis.fetch = ((input: string, init?: RequestInit) => { const answer = widgetsAnswer(input, init); - return answer ? Promise.resolve(answer) : fetchMock(input, init); + if (answer) { + return Promise.resolve(answer); + } + return input === '/api/intent/fields' ? fieldsMock(input, init) : fetchMock(input, init); }) as typeof fetch; }); @@ -199,7 +213,7 @@ describe('HomePage', () => { }); test('suggests a quick action while the input is empty, and none once typing starts', async () => { - fetchMock.mockResolvedValue(Response.json(intent)); + fetchMock.mockImplementation(() => Promise.resolve(Response.json(intent))); renderHomePage(); expect(await screen.findByText('Quick actions:')).toBeTruthy(); @@ -209,7 +223,7 @@ describe('HomePage', () => { }); test('opens what is typed into its widget under the input, and nothing else', async () => { - fetchMock.mockResolvedValue(Response.json(intent)); + fetchMock.mockImplementation(() => Promise.resolve(Response.json(intent))); renderHomePage(); await typeInto('lunch with Jacky Doe at Nopa'); @@ -224,10 +238,71 @@ describe('HomePage', () => { body: JSON.stringify({text: 'lunch with Jacky Doe at Nopa'}), }), ); + expect(fieldsMock).toHaveBeenCalledWith( + '/api/intent/fields', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({text: 'lunch with Jacky Doe at Nopa', widget: 'new_event'}), + }), + ); // The raw answer is not shown. expect(screen.queryByText(/140 ms/)).toBeNull(); }); + test('shows the widget as soon as it is picked, and fills it in when its fields come', async () => { + fetchMock.mockImplementation(() => Promise.resolve(Response.json(intent))); + let fields: (response: Response) => void = () => {}; + fieldsMock.mockImplementationOnce(() => new Promise((resolve) => (fields = resolve))); + renderHomePage(); + + await typeInto('lunch with Jacky Doe'); + const filling = await screen.findByRole('region', {name: 'New event'}); + expect(within(filling).getByText('Filling in…')).toBeTruthy(); + + await waitFor(() => expect(fieldsMock).toHaveBeenCalledTimes(1)); + fields(Response.json({fields: intent.fields, ms: 1})); + expect(await screen.findByText(/jacky\.doe@example\.com/)).toBeTruthy(); + expect(screen.queryByText('Filling in…')).toBeNull(); + }); + + test('the widget on screen, picked again, stays filled in until its new fields come', async () => { + fetchMock.mockImplementation(() => Promise.resolve(Response.json(intent))); + renderHomePage(); + + await typeInto('lunch with Jacky Doe'); + const widget = await screen.findByRole('region', {name: 'New event'}); + expect(await within(widget).findByText(/jacky\.doe@example\.com/)).toBeTruthy(); + + fieldsMock.mockImplementation(() => new Promise(() => {})); + await typeInto('lunch with Jacky Doe at Nopa'); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(screen.queryByText('Filling in…')).toBeNull(); + expect( + within(screen.getByRole('region', {name: 'New event'})).getByText(/jacky\.doe@example\.com/), + ).toBeTruthy(); + }); + + test("fields for a widget no longer on screen aren't shown", async () => { + fetchMock.mockImplementationOnce(() => Promise.resolve(Response.json(intent))); + fetchMock.mockImplementationOnce(() => Promise.resolve(Response.json(person))); + let fields: (response: Response) => void = () => {}; + fieldsMock.mockImplementationOnce(() => new Promise((resolve) => (fields = resolve))); + renderHomePage(); + + await typeInto('lunch with'); + await screen.findByRole('region', {name: 'New event'}); + await typeInto('who is hugo'); + await screen.findByRole('region', {name: 'Person'}); + + expect(fieldsMock).toHaveBeenCalledTimes(2); + fields(Response.json({fields: intent.fields, ms: 1})); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(screen.getByRole('region', {name: 'Person'})).toBeTruthy(); + expect(screen.queryByRole('region', {name: 'New event'})).toBeNull(); + }); + test('says what fits no quick action is for an agent, once typing pauses', async () => { fetchMock.mockImplementation(() => Promise.resolve(Response.json({widget: 'none', fields: [], ms: 1})), @@ -418,7 +493,10 @@ describe('HomePage', () => { test("shows the api's reason when classifying isn't set up", async () => { fetchMock.mockResolvedValue( Response.json( - {error: 'unavailable', message: 'The home page is not set up: set `OPENROUTER_KEY`.'}, + { + error: 'unavailable', + message: 'The home page is not set up: set `TYPESAFE_API_KEY` and `OPENROUTER_KEY`.', + }, {status: 503}, ), ); @@ -427,7 +505,9 @@ describe('HomePage', () => { await typeInto('who is hugo'); expect( - await screen.findByText('The home page is not set up: set `OPENROUTER_KEY`.'), + await screen.findByText( + 'The home page is not set up: set `TYPESAFE_API_KEY` and `OPENROUTER_KEY`.', + ), ).toBeTruthy(); }); }); diff --git a/packages/web/src/pages/home-page.tsx b/packages/web/src/pages/home-page.tsx index da781931..a48ed387 100644 --- a/packages/web/src/pages/home-page.tsx +++ b/packages/web/src/pages/home-page.tsx @@ -1,13 +1,13 @@ import type {FetchError} from '@proxy/utils'; import {useEffect, useRef, useState} from 'react'; import {useLoaderData, useRouteLoaderData} from 'react-router'; -import {classifyIntent, type Intent} from '@/client/intent-client'; +import {fillIntent, type Intent, pickIntent} from '@/client/intent-client'; import type {Me} from '@/client/me-client'; import {ActivityGraph} from '@/components/activity-graph'; import {AnimatedHeight} from '@/components/animated-height'; import {ConnectedLogos} from '@/components/connected-logos'; import {AppShell} from '@/components/app-shell'; -import {IntentWidget} from '@/components/widgets/intent-widget'; +import {FillingWidget, IntentWidget} from '@/components/widgets/intent-widget'; import {UnsupportedWidget} from '@/components/widgets/unsupported-widget'; import {Input} from '@proxy/ui/components/input'; import {useExperiment} from '@/hooks/use-experiment'; @@ -62,8 +62,9 @@ function greeting(me: Me | undefined, now: Date): string { } // An answer, with the text it was for and when it came: a widget reads its times against that. -// `follows` when it came while the answer before it said nothing fits, on screen. -type Answer = {intent: Intent; text: string; at: Date; follows: boolean}; +// `follows` when it came while the answer before it said nothing fits, on screen. `filling` while +// the widget is picked and its fields are still to come. +type Answer = {intent: Intent; text: string; at: Date; follows: boolean; filling: boolean}; // Whether an answer of no widget says so: the model tells text still being typed (`incomplete`, // never kept) from a whole request no widget does (`none`), and as a backstop, `none` waits until @@ -74,8 +75,8 @@ function saysUnsupported(answer: Answer, pausedOn: string): boolean { // The start page, as Sana's: one input, and as it is typed in, it comes forward over the page, // which blurs behind it, and opens into the widget for what is typed, the input at its top. The api -// picks the widget and fills it, every 300 ms while typing goes on; Esc, or a click beside it, puts -// it back. +// picks the widget every 300 ms while typing goes on, and it shows at once, then fills in when its +// fields come; Esc, or a click beside it, puts it back. export function HomePage() { const me = useRouteLoaderData('human'); const banks = useExperiment('plaid'); @@ -94,9 +95,11 @@ export function HomePage() { const typed = useRef(''); const sent = useRef(''); // Requests are numbered as they are sent. An answer is shown when it is newer than the one on - // screen, so answers keep coming while typing goes on, but a slow one never replaces a newer one. + // screen, so answers keep coming while typing goes on, but a slow one never replaces a newer one: + // `shown` is the newest pick on screen, `filled` the newest fields. const sequence = useRef(0); const shown = useRef(0); + const filled = useRef(0); const tick = useRef>(undefined); const input = useRef(null); @@ -113,6 +116,32 @@ export function HomePage() { setPausedOn(words); }; + // The fields of `widget`, picked for `words`, shown when it is still the widget on screen and no + // newer fields are. + const fill = (words: string, widget: string, request: number) => { + void fillIntent(words, widget).then((result) => { + if (request <= filled.current) { + return; + } + if (result.isErr()) { + if (request === shown.current) { + setError(intentErrorMessage(result.error)); + } + return; + } + filled.current = request; + if (request === shown.current) { + setError(null); + } + const {fields} = result.value; + setAnswer((previous) => + previous?.intent.widget === widget + ? {...previous, intent: {widget, fields}, text: words, at: new Date(), filling: false} + : previous, + ); + }); + }; + const send = () => { tick.current = undefined; const words = typed.current; @@ -123,7 +152,7 @@ export function HomePage() { sent.current = words; sequence.current += 1; const request = sequence.current; - void classifyIntent(words).then((result) => { + void pickIntent(words).then((result) => { if (request <= shown.current) { return; } @@ -133,16 +162,26 @@ export function HomePage() { return; } setError(null); + const widget = result.value.widget; // Too little to tell yet: what is on screen stays until there is more. - if (result.value.widget === 'incomplete') { + if (widget === 'incomplete') { return; } - setAnswer((previous) => ({ - intent: result.value, - text: words, - at: new Date(), - follows: previous !== null && saysUnsupported(previous, pausedOnNow.current), - })); + // The widget on screen, picked again, stays as it is filled in until its new fields come. + setAnswer((previous) => + previous?.intent.widget === widget && widget !== 'none' + ? previous + : { + intent: {widget, fields: []}, + text: words, + at: new Date(), + follows: previous !== null && saysUnsupported(previous, pausedOnNow.current), + filling: widget !== 'none', + }, + ); + if (widget !== 'none') { + fill(words, widget, request); + } }); }; @@ -172,6 +211,7 @@ export function HomePage() { pauseOn(''); sent.current = ''; shown.current = sequence.current; + filled.current = sequence.current; }; // A widget asking for something else, as Person's Find emails does: it is typed in anew, and the @@ -186,6 +226,9 @@ export function HomePage() { if (!answer) { return null; } + if (answer.filling) { + return ; + } if (answer.intent.widget !== 'none') { return ( diff --git a/render.yaml b/render.yaml index dfd873ae..3206ea68 100644 --- a/render.yaml +++ b/render.yaml @@ -58,8 +58,10 @@ services: sync: false - key: EMAIL_FROM sync: false - # The model the home page's widgets are picked and filled in with; without it the home page - # says it is not set up. + # Jev, which picks the home page's widget, and the model that fills it in; without both the + # home page says it is not set up. + - key: TYPESAFE_API_KEY + sync: false - key: OPENROUTER_KEY sync: false - key: GOOGLE_CLIENT_ID