diff --git a/.changeset/6169-chatbot-authoring-face-type.md b/.changeset/6169-chatbot-authoring-face-type.md new file mode 100644 index 0000000000..ad7987201b --- /dev/null +++ b/.changeset/6169-chatbot-authoring-face-type.md @@ -0,0 +1,59 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-chatbot': minor +--- + +`ChatbotSchema` names the `chatbot` node's local-display and legacy +auto-response keys — a new, additive published surface (objectui#6169, the +#6172 family ruling: every component node has exactly one named, importable +authoring-face type). + +`ChatbotSchema` (`@object-ui/types`) now declares ten keys that previously +existed ONLY inside an anonymous inline intersection local to +`packages/plugin-chatbot/src/renderer.tsx`'s `chatbot` registration, invisible +to anything outside that one file: + +- `showTimestamp`, `userAvatarUrl`, `userAvatarFallback`, `assistantAvatarUrl`, + `assistantAvatarFallback`, `maxHeight` — display fields. +- `autoResponse`, `autoResponseText`, `autoResponseDelay` — the local + auto-response (demo/playground) fields, already live via a real consumer + (`packages/app-shell/src/console/ai/AiChatPage.tsx`). +- `onSend?: (content: string, messages: ChatMessage[]) => void` — the + send-callback, now typed against the published `ChatMessage` shape rather + than the plugin's internal runtime message type. + +Each was read-site-censused before being declared (renderer.tsx and/or +`useObjectChat.ts` reads every one); none were dead, so none took the +ADR-0049 retirement route. `disabled` — also present in the original +intersection — is NOT redeclared: it is already `BaseSchema.disabled` +(`boolean | string`), read generically for every node type, and redeclaring +it here would have narrowed away the inherited expression-string case. + +**What an external consumer can now do that they could not before:** import +`ChatbotSchema` from `@object-ui/types` and get these ten keys with real, +checked types — previously any reference to them required either duplicating +the anonymous type by hand or falling back to `any`. The Zod mirror +(`@object-ui/types/zod`) gained the same ten keys in lockstep, so a `chatbot` +node parsed through it is now validated on these keys rather than silently +passed through unchecked (`BaseSchema`'s Zod mirror is `.passthrough()`). + +`packages/plugin-chatbot`'s `chatbot` registration (`renderer.tsx`) now types +its `schema` prop as `ChatbotSchema` directly, dropping the anonymous +intersection. No behavior change: `renderer.tsx:87`'s +`body: schema.requestBody` forwarding — the subject of the already-merged +#6193 — is untouched, and the render function reads the exact same keys it +already read. + +This is additive (new optional keys on an interface that already carried a +`[key: string]: any` index signature, and a new Zod-validated subset of +previously-passthrough keys), so it ships as `minor` even though it changes +published type surface: objectui's major is pinned to `@objectstack`'s +(`scripts/check-changeset-no-major.mjs`), and objectui's own breaking changes +ship as `minor` with the break spelled out — there is no break here to spell +out, only a widening from anonymous-and-unchecked to named-and-validated. + +Out of scope, deliberately: the `chatbot-enhanced` and `chatbot-floating` +registrations' own anonymous intersections (different key sets, a decision +for a separate card in the same family), and the `surface` row on +`content/docs/plugins/plugin-chatbot.mdx`'s Properties table, which names a +key no registration in this package currently reads (filed separately). diff --git a/content/docs/plugins/plugin-chatbot.mdx b/content/docs/plugins/plugin-chatbot.mdx index 22b45d7fdd..03b3b17ba8 100644 --- a/content/docs/plugins/plugin-chatbot.mdx +++ b/content/docs/plugins/plugin-chatbot.mdx @@ -129,6 +129,11 @@ const schema = { ## Properties +`showTimestamp` through `onSend` below are declared on [`ChatbotSchema`](https://github.com/objectstack-ai/objectui/blob/main/packages/types/src/complex.ts) +(`@object-ui/types`) — previously they existed only in an anonymous type local +to the renderer, referenceable, validatable and documentable by nothing +outside that one file (objectui#6169). + | Property | Type | Default | Description | |----------|------|---------|-------------| | `messages` | array | `[]` | Initial chat messages | diff --git a/packages/plugin-chatbot/src/renderer.tsx b/packages/plugin-chatbot/src/renderer.tsx index 63b596f963..bd851d0e3d 100644 --- a/packages/plugin-chatbot/src/renderer.tsx +++ b/packages/plugin-chatbot/src/renderer.tsx @@ -59,19 +59,18 @@ import { toRuntimeMessages } from './chatMessageAdapter'; * element, which the sweep gate measured as clean. */ ComponentRegistry.register('chatbot', - ({ schema, className, disabled: hostDisabled, ...props }: { schema: ChatbotSchema & { - showTimestamp?: boolean; - disabled?: boolean; - userAvatarUrl?: string; - userAvatarFallback?: string; - assistantAvatarUrl?: string; - assistantAvatarFallback?: string; - maxHeight?: string; - autoResponse?: boolean; - autoResponseText?: string; - autoResponseDelay?: number; - onSend?: (content: string, messages: ObjectChatMessage[]) => void; - }; className?: string; disabled?: boolean; [key: string]: any }) => { + // The eleven keys this destructure's parameter type used to carry as an + // anonymous inline intersection now live on `ChatbotSchema` itself + // (objectui#6169, the #6172 family ruling: every component node has + // exactly one named, importable authoring-face type) — each was + // read-site-censused first; none were dead, so none took the ADR-0049 + // route. `schema` is that one type, referenceable and documentable from + // outside this file for the first time. `disabled` here is the sibling, + // host-EVALUATED prop (`SchemaRenderer`'s verdict on `schema.disabled` / + // `schema.disabledOn`, forwarded as `hostDisabled`) — a different carrier + // from the authored `schema.disabled` it is derived from; see the comment + // on `disabled={hostDisabled || isLoading}` below. + ({ schema, className, disabled: hostDisabled, ...props }: { schema: ChatbotSchema; className?: string; disabled?: boolean; [key: string]: any }) => { const { messages, isLoading, diff --git a/packages/types/src/__tests__/chatbot-authoring-face-keys.test.ts b/packages/types/src/__tests__/chatbot-authoring-face-keys.test.ts new file mode 100644 index 0000000000..50d0f43a60 --- /dev/null +++ b/packages/types/src/__tests__/chatbot-authoring-face-keys.test.ts @@ -0,0 +1,175 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `ChatbotSchema` declares the `chatbot` node's local-display and legacy + * auto-response keys by name (objectui#6169, the #6172 family ruling: every + * component node has exactly one named, importable authoring-face type). + * + * Before this file, `showTimestamp`, `userAvatarUrl`, `userAvatarFallback`, + * `assistantAvatarUrl`, `assistantAvatarFallback`, `maxHeight`, + * `autoResponse`, `autoResponseText`, `autoResponseDelay` and `onSend` lived + * ONLY in an anonymous inline intersection at + * `packages/plugin-chatbot/src/renderer.tsx`'s `chatbot` registration site — + * nothing outside that one file could reference, validate, or document them. + * Each was read-site-censused before being declared here (renderer.tsx and/or + * `useObjectChat.ts` read every one); none were dead, so none took the + * ADR-0049 route. + * + * `disabled` — also in the original intersection — is deliberately NOT + * redeclared: it is already `BaseSchema.disabled` (`boolean | string`), read + * generically by `SchemaRenderer` for every node type. Redeclaring it here as + * `boolean` only would have narrowed away the expression-string half of an + * inherited field for no reason; the last `it` below pins that it stays wide. + * + * ## Why the TS half is real enforcement, not decoration + * + * `BaseSchema` carries `[key: string]: any` (objectui#5155). Before this + * change, `autoResponseDelay: 'not-a-number'` on a `ChatbotSchema`-typed + * object type-checked FINE — the index signature swallowed the unlisted key + * as `any`. Declaring the field with its real type is what makes a wrong + * value refusable at the type level; the `@ts-expect-error` below is checked + * by `tsc -p tsconfig.test.json` (this package's `type-check` script chains + * it), so a regression that widens the field back toward `any` — or deletes + * it, letting the index signature re-absorb it — fails the build on the + * now-unused directive. A green test RUN cannot show this; only a green + * type-check can (AGENTS.md: "A green type-check is the proof a tombstone + * bites; a green test run is not" — the same instrument, applied to a + * declaration rather than a retirement). + * + * ## Why the Zod half is real enforcement, not decoration + * + * `BaseSchema`'s Zod mirror is `.passthrough()`, inherited through + * `.extend()`. Before this change `ChatbotSchema.safeParse({ ..., + * autoResponseDelay: 'not-a-number' })` returned `success: true` — an + * undeclared key rides through passthrough UNVALIDATED, wrong type and all. + * Mirroring the field is what turns that into a refusal. + */ + +import { describe, it, expect } from 'vitest'; +import type { ChatbotSchema, ChatMessage } from '../complex'; +import { ChatbotSchema as ChatbotZodSchema } from '../zod/complex.zod'; + +const baseMessages: ChatMessage[] = [ + { id: '1', role: 'user', content: 'hi' }, +]; + +describe('ChatbotSchema: the ten local-display/legacy keys are declared, not anonymous (objectui#6169)', () => { + it('accepts every key at its declared type on the TypeScript interface', () => { + const onSend: ChatbotSchema['onSend'] = (content, messages) => { + expect(typeof content).toBe('string'); + expect(Array.isArray(messages)).toBe(true); + }; + + const node: ChatbotSchema = { + type: 'chatbot', + messages: baseMessages, + showTimestamp: true, + userAvatarUrl: 'https://example.com/user.png', + userAvatarFallback: 'You', + assistantAvatarUrl: 'https://example.com/assistant.png', + assistantAvatarFallback: 'AI', + maxHeight: '500px', + autoResponse: true, + autoResponseText: 'Thanks!', + autoResponseDelay: 1000, + onSend, + }; + + expect(node.showTimestamp).toBe(true); + expect(node.autoResponseDelay).toBe(1000); + node.onSend?.('hello', baseMessages); + }); + + it('refuses a wrong-typed value on a declared key — proof the field is no longer `any` via the index signature', () => { + const node: ChatbotSchema = { + type: 'chatbot', + messages: baseMessages, + // @ts-expect-error `autoResponseDelay` is declared `number`; before this + // field was named, `[key: string]: any` on `BaseSchema` swallowed any + // value at any unlisted key and this line type-checked. + autoResponseDelay: 'not-a-number', + }; + // Runtime shape is unaffected by the type-level assertion above. + expect(node.type).toBe('chatbot'); + }); + + it('keeps `disabled` inherited from BaseSchema (`boolean | string`), not redeclared narrower', () => { + // `BaseSchema.disabled` accepts an expression STRING (evaluated by + // `evaluator.evaluateCondition`), not just a boolean. If this key were + // redeclared inside the new group as `boolean` only, this line would be + // the one to catch it. + const node: ChatbotSchema = { + type: 'chatbot', + messages: baseMessages, + disabled: 'record.locked === true', + }; + expect(node.disabled).toBe('record.locked === true'); + }); + + it('accepts every key at its declared type through the Zod mirror', () => { + const result = ChatbotZodSchema.safeParse({ + type: 'chatbot', + messages: [{ id: '1', role: 'user', content: 'hi' }], + showTimestamp: true, + userAvatarUrl: 'https://example.com/user.png', + userAvatarFallback: 'You', + assistantAvatarUrl: 'https://example.com/assistant.png', + assistantAvatarFallback: 'AI', + maxHeight: '500px', + autoResponse: true, + autoResponseText: 'Thanks!', + autoResponseDelay: 1000, + onSend: () => {}, + }); + + expect(result.success).toBe(true); + }); + + it('refuses a wrong-typed value on a declared key through the Zod mirror (was silently passed through before)', () => { + const result = ChatbotZodSchema.safeParse({ + type: 'chatbot', + messages: [], + autoResponseDelay: 'not-a-number', + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some( + (issue) => issue.path.join('.') === 'autoResponseDelay' && issue.code === 'invalid_type', + ), + ).toBe(true); + } + }); + + it('lists all ten keys in the Zod mirror shape (mirrored, not merely passed through)', () => { + const shapeKeys = Object.keys(ChatbotZodSchema.shape); + for (const key of [ + 'showTimestamp', + 'userAvatarUrl', + 'userAvatarFallback', + 'assistantAvatarUrl', + 'assistantAvatarFallback', + 'maxHeight', + 'autoResponse', + 'autoResponseText', + 'autoResponseDelay', + 'onSend', + ]) { + expect(shapeKeys).toContain(key); + } + // `disabled` DOES appear here too — `.extend()`'s `.shape` merges the + // parent's fields into the child's, so BaseSchema's `disabled` shows up + // without this file's `.extend({...})` call ever naming it. That merge is + // exactly why it was correct not to re-declare `disabled` above: doing so + // would have SHADOWED the inherited `boolean | string` entry with a + // narrower one, rather than adding a new key. + expect(shapeKeys).toContain('disabled'); + }); +}); diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index 1472fcf52c..d55a5d4f77 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -641,6 +641,71 @@ export interface ChatbotSchema extends BaseSchema { */ onError?: (error: Error) => void; + // --- Local display + legacy auto-response fields (objectui#6169) --- + // + // Lifted from an anonymous inline intersection that used to live ONLY at + // `packages/plugin-chatbot/src/renderer.tsx`'s `chatbot` registration site + // (`ComponentRegistry.register('chatbot', ...)`), where nothing outside + // that one file could reference, validate, or document them. Each key + // below was read-site-censused before being declared here — every one has + // a live reader in `renderer.tsx` and/or `useObjectChat.ts`; none were + // dead. `disabled` is deliberately NOT redeclared in this group: it is + // already `BaseSchema.disabled` (`boolean | string`), read generically by + // `SchemaRenderer` for every node type, not specific to `chatbot`. + + /** + * Display a timestamp on each message. + * @default false + */ + showTimestamp?: boolean; + /** + * URL of the user's avatar image. + */ + userAvatarUrl?: string; + /** + * Fallback text shown when `userAvatarUrl` is not set or fails to load. + * @default 'You' + */ + userAvatarFallback?: string; + /** + * URL of the assistant's avatar image. + */ + assistantAvatarUrl?: string; + /** + * Fallback text shown when `assistantAvatarUrl` is not set or fails to load. + * @default 'AI' + */ + assistantAvatarFallback?: string; + /** + * Maximum height of the chat message container (CSS value). + * @default '500px' + */ + maxHeight?: string; + /** + * Enable local auto-response (demo/playground) mode. Ignored once `api` + * is set — API mode replaces the local echo entirely. + * @default false + */ + autoResponse?: boolean; + /** + * The text of the local auto-response, used when `autoResponse` is true. + */ + autoResponseText?: string; + /** + * Delay in milliseconds before the local auto-response is sent. + * @default 1000 + */ + autoResponseDelay?: number; + /** + * Called after a message is sent, in both API and local auto-response + * mode, with the trimmed content and the full message list at that + * point. `messages` here is the same authoring-side {@link ChatMessage} + * shape as the `messages` field above; the plugin's own runtime message + * type is a structural superset (objectui#4424) and still satisfies a + * handler typed against this narrower, published shape. + */ + onSend?: (content: string, messages: ChatMessage[]) => void; + // --- Floating / FAB display mode --- /** diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index cf2accb6f4..5663070996 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -310,6 +310,21 @@ export const ChatbotSchema = BaseSchema.extend({ maxToolRoundtrips: z.number().optional() .describe('DEPRECATED (inert, slated for removal) — Max tool-calling round-trips. Nothing reads this; cap tool loops on the agent via planning.maxIterations'), onError: z.function().optional().describe('Error callback'), + // --- Local display + legacy auto-response fields (objectui#6169) --- + // Mirrors the TS declaration added at ../complex.ts in lockstep, so these + // ten keys move from the pre-existing "declared but unmirrored, rides + // through .passthrough() unvalidated" state straight to mirrored — never + // through an interim unmirrored window. + showTimestamp: z.boolean().optional().describe('Display a timestamp on each message'), + userAvatarUrl: z.string().optional().describe("URL of the user's avatar image"), + userAvatarFallback: z.string().optional().describe('Fallback text for the user avatar'), + assistantAvatarUrl: z.string().optional().describe("URL of the assistant's avatar image"), + assistantAvatarFallback: z.string().optional().describe('Fallback text for the assistant avatar'), + maxHeight: z.string().optional().describe('Maximum height of the chat message container (CSS value)'), + autoResponse: z.boolean().optional().describe('Enable local auto-response (demo/playground) mode'), + autoResponseText: z.string().optional().describe('Text of the local auto-response'), + autoResponseDelay: z.number().optional().describe('Delay in milliseconds before the local auto-response is sent'), + onSend: z.function().optional().describe('Called after a message is sent, in both API and local auto-response mode'), }); /**