This is an automated email from the ASF dual-hosted git repository. EnxDev pushed a commit to branch enxdev/chat-prototype in repository https://gitbox.apache.org/repos/asf/superset.git
commit 2b884cb3fa9415701cf1ec1269d63db075486648 Author: Enzo Martellucci <[email protected]> AuthorDate: Tue May 26 16:12:17 2026 +0200 feat(extensions): reference chatbot extension — docs and example Ships the full extensions/chat reference implementation that exercises the chatbot extension platform end-to-end: activation lifecycle and master disposable (teardown contract), React error boundary (fault isolation), mock streaming with AbortController cancellation, commands registration, and the pageContext helper that composes host namespaces. Local branch only — not intended for upstream merge. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> --- extensions/chat/README.md | 138 ++++++++++++++ extensions/chat/extension.json | 40 ++++ extensions/chat/jest.config.js | 53 ++++++ extensions/chat/package.json | 26 +++ extensions/chat/src/ReferenceChatbot.tsx | 45 +++++ extensions/chat/src/__tests__/activate.test.tsx | 144 ++++++++++++++ extensions/chat/src/__tests__/sdkMock.ts | 119 ++++++++++++ extensions/chat/src/activate.ts | 88 +++++++++ extensions/chat/src/commands.ts | 47 +++++ extensions/chat/src/components/Bubble.tsx | 46 +++++ extensions/chat/src/components/ErrorBoundary.tsx | 66 +++++++ extensions/chat/src/components/Panel.tsx | 228 +++++++++++++++++++++++ extensions/chat/src/context/pageContext.ts | 114 ++++++++++++ extensions/chat/src/index.tsx | 30 +++ extensions/chat/src/state.ts | 57 ++++++ extensions/chat/src/streaming/mockStream.ts | 73 ++++++++ extensions/chat/src/streaming/registry.ts | 46 +++++ extensions/chat/tsconfig.json | 15 ++ extensions/chat/tsconfig.test.json | 15 ++ extensions/chat/webpack.config.js | 70 +++++++ 20 files changed, 1460 insertions(+) diff --git a/extensions/chat/README.md b/extensions/chat/README.md new file mode 100644 index 00000000000..dece34ba327 --- /dev/null +++ b/extensions/chat/README.md @@ -0,0 +1,138 @@ +<!-- +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +--> + +# Reference Chatbot Extension + +Canonical environment-validation extension for the `superset.chatbot` +contribution area. **Not** a product chatbot — there is no LLM, no backend, +no persistence. Its purpose is to exercise the extension platform end-to-end: + +- `views.registerView` at `superset.chatbot` (singleton resolution) +- Lifecycle activation + a master disposable that tears down everything +- `commands.registerCommand` for `core.chatbot__open|close|toggle` +- Mock streaming with `AbortController` cancellation on dispose +- Defense-in-depth React error boundary inside the panel +- A single P3 page-context seam that lights up automatically as the + `dashboard` / `explore` / `dataset` / `navigation` namespaces become + available at runtime on the host + +It is intended as the reference implementation third-party chatbot extension +authors copy. Anything that ships as host-internal (the mount point, the +admin picker, the `getActiveChatbot` resolver) is **not** here — see the +host side at `superset-frontend/src/components/ChatbotMount/` and +`superset-frontend/src/core/chatbot/`. + +## Layout + +``` +extensions/chat/ +├── extension.json Manifest (app.chatbot view + commands) +├── package.json +├── tsconfig.json +├── webpack.config.js ModuleFederation → window.superset +├── jest.config.js Self-contained unit tests +└── src/ + ├── index.tsx MF entry — calls activate() once + ├── activate.ts Returns master disposable + ├── commands.ts core.chatbot__open|close|toggle + ├── state.ts Module-scoped open/closed + emitter + ├── ReferenceChatbot.tsx Root component (bubble ↔ panel) + ├── components/ + │ ├── Bubble.tsx + │ ├── Panel.tsx + │ └── ErrorBoundary.tsx + ├── streaming/ + │ ├── mockStream.ts AsyncIterable<string> + AbortSignal + │ └── registry.ts Cross-component abort tracking + ├── context/ + │ └── pageContext.ts P3 namespace seam (defensive) + └── __tests__/ + ├── sdkMock.ts In-memory @apache-superset/core mock + └── activate.test.tsx +``` + +## Run the unit tests + +```bash +cd extensions/chat +npm install # first time only +npx jest +``` + +The tests mock `@apache-superset/core` via `src/__tests__/sdkMock.ts` so they +do not depend on host runtime wiring. + +## Build / bundle for deployment + +```bash +# from the extension folder +npm install +npm run build + +# packaging into a .supx is handled by the Superset extensions CLI +pip install apache-superset-extensions-cli +superset-extensions bundle # produces apache-superset.reference-chatbot-0.1.0.supx +``` + +Drop the `.supx` into the `EXTENSIONS_PATH` of a Superset instance that has +`FEATURE_FLAGS = { "ENABLE_EXTENSIONS": True }`. + +## Selecting it as the active chatbot + +The host's singleton picker reads `active_chatbot_id` from the admin +settings endpoint (`/api/v1/extensions/settings`). Set it to: + +``` +apache-superset.reference-chatbot +``` + +If no admin selection exists, the host falls back to the first-to-register +chatbot — installing this extension alone is enough for the bubble to appear. + +## P3 integration seams + +All page-context derivation lives in [`src/context/pageContext.ts`](src/context/pageContext.ts). +Each namespace branch (`dashboard`, `explore`, `dataset`, `navigation`) is +called defensively — when the host implementation lands, the returned value +becomes non-undefined automatically with no other change in the extension. + +The panel re-reads context on `popstate`. Once `navigation.onDidChangePage` +is live on the host, the panel's `useEffect` should subscribe to it instead; +that is the only file in the extension that needs to change for full P3 +context sync. + +## Known intentional non-features + +- No conversation persistence — by design (extension scope per SIP §2). +- No real network. The mock stream is a `setTimeout` token emitter so the + cancellation contract is exercised without external dependencies. +- No keyboard shortcut binding (Cmd+K). Extensions own that, but it adds + surface area not needed for platform validation. +- No notification badge / icon mutation. SIP §3.2 recommends static icons; + the bubble re-renders freely already. + +## TODOs + +- **P1**: if/when the host gains `deactivate(): Promise<void>`, wrap the + master disposer in `activate.ts` to flush async work before returning. +- **P3**: replace the `popstate` listener in `Panel.tsx` with + `navigation.onDidChangePage` once that event is wired up host-side. +- **P4**: if the host pre-registers `core.chatbot__*` as host-owned intents, + swap `commands.registerCommand` for the implementation hook in + `commands.ts`. Command IDs do not change. diff --git a/extensions/chat/extension.json b/extensions/chat/extension.json new file mode 100644 index 00000000000..58fc98c7e3c --- /dev/null +++ b/extensions/chat/extension.json @@ -0,0 +1,40 @@ +{ + "publisher": "apache-superset", + "name": "reference-chatbot", + "displayName": "Reference Chatbot", + "description": "Canonical environment-validation chatbot extension for the superset.chatbot contribution area. Exercises registration, lifecycle, singleton resolution, commands, fault isolation, and streaming teardown. Not a product chatbot.", + "version": "0.1.0", + "license": "Apache-2.0", + "permissions": [], + "contributes": { + "views": { + "app": { + "chatbot": [ + { + "id": "apache-superset.reference-chatbot", + "name": "Reference Chatbot", + "description": "Validates the chatbot extension environment end-to-end.", + "icon": "Bubble" + } + ] + } + }, + "commands": [ + { + "id": "core.chatbot__open", + "title": "Open chatbot", + "description": "Opens the reference chatbot panel." + }, + { + "id": "core.chatbot__close", + "title": "Close chatbot", + "description": "Closes the reference chatbot panel." + }, + { + "id": "core.chatbot__toggle", + "title": "Toggle chatbot", + "description": "Toggles the reference chatbot panel." + } + ] + } +} diff --git a/extensions/chat/jest.config.js b/extensions/chat/jest.config.js new file mode 100644 index 00000000000..7151d801622 --- /dev/null +++ b/extensions/chat/jest.config.js @@ -0,0 +1,53 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +const path = require('path'); + +// When run as a standalone package (`cd extensions/chat && npm test`), modules +// resolve from this folder's own node_modules. When run from the superset-frontend +// workspace (CI, dev convenience), resolve ts-jest there too. +const tsJest = (() => { + try { + require.resolve('ts-jest'); + return 'ts-jest'; + } catch { + return path.resolve( + __dirname, + '..', + '..', + 'superset-frontend', + 'node_modules', + 'ts-jest', + ); + } +})(); + +module.exports = { + testEnvironment: 'jsdom', + rootDir: __dirname, + testMatch: ['<rootDir>/src/**/*.test.{ts,tsx}'], + // When running from the extension folder without node_modules installed, + // resolve react / react-dom from the superset-frontend workspace. + modulePaths: [path.resolve(__dirname, '..', '..', 'superset-frontend', 'node_modules')], + moduleNameMapper: { + '^@apache-superset/core$': '<rootDir>/src/__tests__/sdkMock.ts', + }, + transform: { + '^.+\\.tsx?$': [tsJest, { tsconfig: '<rootDir>/tsconfig.test.json' }], + }, +}; diff --git a/extensions/chat/package.json b/extensions/chat/package.json new file mode 100644 index 00000000000..ace4b103324 --- /dev/null +++ b/extensions/chat/package.json @@ -0,0 +1,26 @@ +{ + "name": "@apache-superset/reference-chatbot", + "version": "0.1.0", + "private": true, + "license": "Apache-2.0", + "description": "Reference chatbot extension that validates the Superset chatbot extension platform.", + "scripts": { + "start": "webpack serve --mode development", + "build": "webpack --stats-error-details --mode production" + }, + "peerDependencies": { + "@apache-superset/core": "^0.1.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@apache-superset/core": "^0.1.0", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "ts-loader": "^9.5.0", + "typescript": "^5.0.0", + "webpack": "^5.0.0", + "webpack-cli": "^5.0.0", + "webpack-dev-server": "^5.0.0" + } +} diff --git a/extensions/chat/src/ReferenceChatbot.tsx b/extensions/chat/src/ReferenceChatbot.tsx new file mode 100644 index 00000000000..971950bc8c4 --- /dev/null +++ b/extensions/chat/src/ReferenceChatbot.tsx @@ -0,0 +1,45 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React, { useEffect, useState } from 'react'; +import { commands } from '@apache-superset/core'; +import { Bubble } from './components/Bubble'; +import { Panel } from './components/Panel'; +import { ExtensionErrorBoundary } from './components/ErrorBoundary'; +import { isOpen, subscribe } from './state'; + +/** + * Root extension component. Mirrors module-state into React via `subscribe` + * so the bubble↔panel transition is driven by the same command handlers + * that external callers use (`core.chatbot__open`, `__close`, `__toggle`). + */ +export const ReferenceChatbot: React.FC = () => { + const [open, setOpenState] = useState<boolean>(isOpen()); + + useEffect(() => subscribe(setOpenState), []); + + return ( + <ExtensionErrorBoundary> + {open ? ( + <Panel onClose={() => commands.executeCommand('core.chatbot__close')} /> + ) : ( + <Bubble onClick={() => commands.executeCommand('core.chatbot__open')} /> + )} + </ExtensionErrorBoundary> + ); +}; diff --git a/extensions/chat/src/__tests__/activate.test.tsx b/extensions/chat/src/__tests__/activate.test.tsx new file mode 100644 index 00000000000..99926c9706b --- /dev/null +++ b/extensions/chat/src/__tests__/activate.test.tsx @@ -0,0 +1,144 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { commands } from '@apache-superset/core'; +import { registry, reset } from './sdkMock'; +import { activate, VIEW_ID, CHATBOT_LOCATION } from '../activate'; +import { isOpen } from '../state'; +import { streamReply } from '../streaming/mockStream'; +import { + registerActiveController, + unregisterActiveController, + abortAllActiveControllers, +} from '../streaming/registry'; + +beforeEach(() => { + reset(); +}); + +test('registers one view at superset.chatbot and three chatbot commands', () => { + const disposable = activate(); + + try { + expect(registry.views.size).toBe(1); + const entry = registry.views.get(VIEW_ID); + expect(entry?.location).toBe(CHATBOT_LOCATION); + expect(entry?.view.icon).toBe('Bubble'); + + expect(Array.from(registry.commands.keys()).sort()).toEqual([ + 'core.chatbot__close', + 'core.chatbot__open', + 'core.chatbot__toggle', + ]); + } finally { + disposable.dispose(); + } +}); + +test('executeCommand drives open/close/toggle through module state', async () => { + const disposable = activate(); + try { + expect(isOpen()).toBe(false); + + await commands.executeCommand('core.chatbot__open'); + expect(isOpen()).toBe(true); + + await commands.executeCommand('core.chatbot__toggle'); + expect(isOpen()).toBe(false); + + await commands.executeCommand('core.chatbot__toggle'); + expect(isOpen()).toBe(true); + + await commands.executeCommand('core.chatbot__close'); + expect(isOpen()).toBe(false); + } finally { + disposable.dispose(); + } +}); + +test('disposing the master disposable unregisters view + commands', () => { + const disposable = activate(); + expect(registry.views.size).toBe(1); + expect(registry.commands.size).toBe(3); + + disposable.dispose(); + + expect(registry.views.size).toBe(0); + expect(registry.commands.size).toBe(0); +}); + +test('disposal is idempotent', () => { + const disposable = activate(); + disposable.dispose(); + expect(() => disposable.dispose()).not.toThrow(); + expect(registry.views.size).toBe(0); +}); + +test('re-activate after dispose works (validates replace semantics)', () => { + const first = activate(); + first.dispose(); + + const second = activate(); + try { + expect(registry.views.size).toBe(1); + expect(registry.commands.size).toBe(3); + expect(isOpen()).toBe(false); // resetState() cleared open flag + } finally { + second.dispose(); + } +}); + +test('aborting an active controller stops the stream cleanly', async () => { + const controller = new AbortController(); + registerActiveController(controller); + + const iter = streamReply('hello world', controller.signal); + const received: string[] = []; + + const consume = (async () => { + for await (const tok of iter) received.push(tok); + })(); + + // Abort after a single tick — the iterator must return without throwing. + await new Promise(r => setTimeout(r, 50)); + abortAllActiveControllers(); + await expect(consume).resolves.toBeUndefined(); + + unregisterActiveController(controller); + expect(received.length).toBeLessThan(20); // would be ~20+ tokens if uncancelled +}); + +test('disposing the extension aborts any in-flight controller', async () => { + const disposable = activate(); + const controller = new AbortController(); + registerActiveController(controller); + + const iter = streamReply('a longer prompt to ensure many tokens', controller.signal); + const consume = (async () => { + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars + for await (const _tok of iter) { + // drain + } + })(); + + await new Promise(r => setTimeout(r, 30)); + disposable.dispose(); + + await expect(consume).resolves.toBeUndefined(); + expect(controller.signal.aborted).toBe(true); +}); diff --git a/extensions/chat/src/__tests__/sdkMock.ts b/extensions/chat/src/__tests__/sdkMock.ts new file mode 100644 index 00000000000..60b089807b9 --- /dev/null +++ b/extensions/chat/src/__tests__/sdkMock.ts @@ -0,0 +1,119 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * In-memory mock of `@apache-superset/core` for unit-testing the extension. + * + * Mirrors only the surfaces the reference chatbot consumes: + * - views.registerView returns a disposable that removes the view + * - commands.registerCommand / executeCommand round-trip handlers + * - sqlLab.getCurrentTab returns undefined (no SQL Lab in tests) + * + * The mock is intentionally observable: tests can read `registry.views` and + * `registry.commands` to assert contract compliance. + */ + +import type { ReactElement } from 'react'; + +type Provider = () => ReactElement; + +interface ViewDescriptor { + id: string; + name: string; + icon?: string; + description?: string; +} + +interface DisposableLike { + dispose(): void; +} + +interface RegisteredView { + view: ViewDescriptor; + location: string; + provider: Provider; +} + +interface RegisteredCommand { + id: string; + title: string; + handler: (...args: any[]) => any; +} + +export const registry = { + views: new Map<string, RegisteredView>(), + commands: new Map<string, RegisteredCommand>(), +}; + +export const reset = (): void => { + registry.views.clear(); + registry.commands.clear(); +}; + +export const views = { + registerView( + view: ViewDescriptor, + location: string, + provider: Provider, + ): DisposableLike { + registry.views.set(view.id, { view, location, provider }); + return { + dispose: () => { + registry.views.delete(view.id); + }, + }; + }, + getViews(location: string) { + return Array.from(registry.views.values()) + .filter(v => v.location === location) + .map(v => v.view); + }, +}; + +export const commands = { + registerCommand( + command: { id: string; title: string }, + handler: (...args: any[]) => any, + ): DisposableLike { + registry.commands.set(command.id, { + id: command.id, + title: command.title, + handler, + }); + return { + dispose: () => { + registry.commands.delete(command.id); + }, + }; + }, + async executeCommand(id: string, ...rest: any[]): Promise<unknown> { + const cmd = registry.commands.get(id); + return cmd?.handler(...rest); + }, + getCommands() { + return Array.from(registry.commands.values()).map(c => ({ + id: c.id, + title: c.title, + })); + }, +}; + +export const sqlLab = { + getCurrentTab: () => undefined, +}; diff --git a/extensions/chat/src/activate.ts b/extensions/chat/src/activate.ts new file mode 100644 index 00000000000..1c48ba7249f --- /dev/null +++ b/extensions/chat/src/activate.ts @@ -0,0 +1,88 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { views } from '@apache-superset/core'; +import { ReferenceChatbot } from './ReferenceChatbot'; +import { registerChatbotCommands } from './commands'; +import { abortAllActiveControllers } from './streaming/registry'; +import { resetState } from './state'; + +export const VIEW_ID = 'apache-superset.reference-chatbot'; +export const CHATBOT_LOCATION = 'superset.chatbot'; + +interface DisposableLike { + dispose(): void; +} + +/** + * Registers the reference chatbot and returns a single disposable that + * tears down everything it created. Idempotent across activate/dispose cycles. + * + * Cleanup order matters: stop in-flight streams first so listeners do not + * receive late tokens, then unregister commands (so user clicks during teardown + * become no-ops), then unregister the view (so the host's ChatbotMount unmounts + * the React tree), and finally reset module state. + * + * Returns a plain `{ dispose }` object rather than constructing a Disposable + * from the SDK — the SDK class is host-injected and only reliably available + * via window.superset at runtime, while plain disposable-likes work in both + * runtime and unit-test contexts. + * + * TODO(P1): when the host gains an async `deactivate(): Promise<void>` hook, + * wrap the master disposer to flush in-flight async work before returning. + */ +export const activate = (): DisposableLike => { + const commandDisposables = registerChatbotCommands(); + const viewDisposable = views.registerView( + { + id: VIEW_ID, + name: 'Reference Chatbot', + icon: 'Bubble', + description: 'Validates the chatbot extension environment end-to-end.', + }, + CHATBOT_LOCATION, + () => React.createElement(ReferenceChatbot), + ); + + let disposed = false; + return { + dispose() { + if (disposed) return; + disposed = true; + try { + abortAllActiveControllers(); + } catch { + // streams are best-effort during teardown + } + commandDisposables.forEach(d => { + try { + d.dispose(); + } catch { + // a single command failing to unregister must not block the rest + } + }); + try { + viewDisposable.dispose(); + } catch { + // ignore + } + resetState(); + }, + }; +}; diff --git a/extensions/chat/src/commands.ts b/extensions/chat/src/commands.ts new file mode 100644 index 00000000000..4f19a8610b2 --- /dev/null +++ b/extensions/chat/src/commands.ts @@ -0,0 +1,47 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { commands } from '@apache-superset/core'; +import { isOpen, setOpen } from './state'; + +interface DisposableLike { + dispose(): void; +} + +/** + * Registers the three chatbot intent commands and returns their disposables. + * + * TODO(P4): if/when the host pre-registers `core.chatbot__*` as host-owned + * intents that extensions implement instead of own, swap registerCommand for + * the implementation hook. The command ids stay the same so call sites do not + * change. + */ +export const registerChatbotCommands = (): DisposableLike[] => [ + commands.registerCommand( + { id: 'core.chatbot__open', title: 'Open chatbot' }, + () => setOpen(true), + ), + commands.registerCommand( + { id: 'core.chatbot__close', title: 'Close chatbot' }, + () => setOpen(false), + ), + commands.registerCommand( + { id: 'core.chatbot__toggle', title: 'Toggle chatbot' }, + () => setOpen(!isOpen()), + ), +]; diff --git a/extensions/chat/src/components/Bubble.tsx b/extensions/chat/src/components/Bubble.tsx new file mode 100644 index 00000000000..488ccc0cb4e --- /dev/null +++ b/extensions/chat/src/components/Bubble.tsx @@ -0,0 +1,46 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; + +interface Props { + onClick: () => void; +} + +export const Bubble: React.FC<Props> = ({ onClick }) => ( + <button + type="button" + onClick={onClick} + data-test="reference-chatbot-bubble" + aria-label="Open reference chatbot" + style={{ + width: 56, + height: 56, + borderRadius: '50%', + border: 'none', + background: '#1f6feb', + color: '#fff', + fontSize: 24, + fontWeight: 600, + cursor: 'pointer', + boxShadow: '0 4px 14px rgba(0,0,0,0.18)', + }} + > + ? + </button> +); diff --git a/extensions/chat/src/components/ErrorBoundary.tsx b/extensions/chat/src/components/ErrorBoundary.tsx new file mode 100644 index 00000000000..0d173a9068a --- /dev/null +++ b/extensions/chat/src/components/ErrorBoundary.tsx @@ -0,0 +1,66 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; + +interface State { + error: Error | null; +} + +/** + * Defense-in-depth boundary. The host already wraps the mount in its own + * ErrorBoundary; this one keeps a panel crash from also bringing down the + * bubble next to it. + */ +export class ExtensionErrorBoundary extends React.Component< + React.PropsWithChildren<{}>, + State +> { + state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + componentDidCatch(error: Error): void { + // eslint-disable-next-line no-console + console.error('[reference-chatbot] render error', error); + } + + render() { + if (this.state.error) { + return ( + <div + data-test="reference-chatbot-error" + style={{ + padding: 12, + border: '1px solid #f5222d', + borderRadius: 6, + background: '#fff1f0', + color: '#a8071a', + fontSize: 12, + maxWidth: 320, + }} + > + Reference chatbot crashed: {this.state.error.message} + </div> + ); + } + return <>{this.props.children}</>; + } +} diff --git a/extensions/chat/src/components/Panel.tsx b/extensions/chat/src/components/Panel.tsx new file mode 100644 index 00000000000..895eb4ada47 --- /dev/null +++ b/extensions/chat/src/components/Panel.tsx @@ -0,0 +1,228 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { streamReply } from '../streaming/mockStream'; +import { getPageContext, PageContext, subscribeToPageChanges } from '../context/pageContext'; +import { registerActiveController, unregisterActiveController } from '../streaming/registry'; + +interface Props { + onClose: () => void; +} + +interface Message { + id: number; + from: 'user' | 'bot'; + text: string; +} + +let messageSeq = 0; + +export const Panel: React.FC<Props> = ({ onClose }) => { + const [input, setInput] = useState(''); + const [messages, setMessages] = useState<Message[]>([]); + const [streaming, setStreaming] = useState(false); + const [pageContext, setPageContext] = useState<PageContext>(() => getPageContext()); + const controllerRef = useRef<AbortController | null>(null); + + useEffect( + () => subscribeToPageChanges(() => setPageContext(getPageContext())), + [], + ); + + useEffect( + () => () => { + // Component unmount cancels any in-flight stream. + controllerRef.current?.abort(); + }, + [], + ); + + const send = useCallback(async () => { + const prompt = input.trim(); + if (!prompt || streaming) return; + setInput(''); + const userMsg: Message = { id: ++messageSeq, from: 'user', text: prompt }; + const botMsg: Message = { id: ++messageSeq, from: 'bot', text: '' }; + setMessages(prev => [...prev, userMsg, botMsg]); + setStreaming(true); + + const controller = new AbortController(); + controllerRef.current = controller; + registerActiveController(controller); + + try { + for await (const token of streamReply(prompt, controller.signal)) { + setMessages(prev => + prev.map(m => (m.id === botMsg.id ? { ...m, text: m.text + token } : m)), + ); + } + } finally { + unregisterActiveController(controller); + controllerRef.current = null; + setStreaming(false); + } + }, [input, streaming]); + + const cancel = useCallback(() => { + controllerRef.current?.abort(); + }, []); + + return ( + <div + data-test="reference-chatbot-panel" + style={{ + width: 360, + maxHeight: 480, + display: 'flex', + flexDirection: 'column', + background: '#fff', + border: '1px solid #d9d9d9', + borderRadius: 8, + boxShadow: '0 8px 24px rgba(0,0,0,0.18)', + overflow: 'hidden', + fontSize: 13, + }} + > + <header + style={{ + padding: '8px 12px', + background: '#1f6feb', + color: '#fff', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + }} + > + <span>Reference Chatbot</span> + <button + type="button" + onClick={onClose} + aria-label="Close chatbot" + data-test="reference-chatbot-close" + style={{ + background: 'transparent', + border: 'none', + color: '#fff', + fontSize: 16, + cursor: 'pointer', + }} + > + × + </button> + </header> + + <div + data-test="reference-chatbot-context" + style={{ + padding: '6px 12px', + background: '#f6f8fa', + borderBottom: '1px solid #eaecef', + fontFamily: 'monospace', + fontSize: 11, + color: '#57606a', + wordBreak: 'break-all', + }} + > + page: {pageContext.pageType} + {pageContext.sqlLab ? ` · tab: ${pageContext.sqlLab.title}` : ''} + </div> + + <div style={{ flex: 1, overflowY: 'auto', padding: 12 }}> + {messages.length === 0 && ( + <p style={{ color: '#8c8c8c' }}> + Ask anything — replies are canned tokens streamed by the reference extension. + </p> + )} + {messages.map(m => ( + <div + key={m.id} + data-test={`reference-chatbot-msg-${m.from}`} + style={{ + margin: '6px 0', + textAlign: m.from === 'user' ? 'right' : 'left', + }} + > + <span + style={{ + display: 'inline-block', + padding: '4px 8px', + borderRadius: 6, + background: m.from === 'user' ? '#1f6feb' : '#eef0f3', + color: m.from === 'user' ? '#fff' : '#1f2328', + maxWidth: '85%', + whiteSpace: 'pre-wrap', + }} + > + {m.text || '…'} + </span> + </div> + ))} + </div> + + <footer + style={{ + padding: 8, + borderTop: '1px solid #eaecef', + display: 'flex', + gap: 6, + }} + > + <input + aria-label="Chat input" + data-test="reference-chatbot-input" + value={input} + onChange={e => setInput(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + send(); + } + }} + placeholder="Type a message" + style={{ + flex: 1, + padding: '4px 8px', + border: '1px solid #d9d9d9', + borderRadius: 4, + }} + /> + {streaming ? ( + <button + type="button" + onClick={cancel} + data-test="reference-chatbot-cancel" + style={{ padding: '4px 10px' }} + > + Stop + </button> + ) : ( + <button + type="button" + onClick={send} + data-test="reference-chatbot-send" + disabled={!input.trim()} + style={{ padding: '4px 10px' }} + > + Send + </button> + )} + </footer> + </div> + ); +}; diff --git a/extensions/chat/src/context/pageContext.ts b/extensions/chat/src/context/pageContext.ts new file mode 100644 index 00000000000..bc6bc92c060 --- /dev/null +++ b/extensions/chat/src/context/pageContext.ts @@ -0,0 +1,114 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Single integration seam for the P3 namespaces. + * + * Each surface namespace is consumed via a try/catch — the host may ship a + * version where a namespace function is declared but not yet implemented at + * runtime, and the reference extension must keep working in that case. As + * each namespace lights up on the host, that branch starts returning real + * data without any change here. + * + * Route inference is the fallback when navigation.getPageType() is absent. + */ + +import * as core from '@apache-superset/core'; + +export type PageType = + | 'home' + | 'dashboard' + | 'chart' + | 'sqllab' + | 'dataset' + | 'unknown'; + +export interface PageContext { + pageType: PageType; + dashboard?: unknown; + chart?: unknown; + dataset?: unknown; + sqlLab?: { tabId: string; title: string }; + href: string; +} + +const tryCall = <T>(fn: () => T | undefined): T | undefined => { + try { + return fn(); + } catch { + return undefined; + } +}; + +const inferPageType = (pathname: string): PageType => { + if (pathname.startsWith('/sqllab')) return 'sqllab'; + if ( + pathname.startsWith('/superset/dashboard') || + pathname.startsWith('/dashboard') + ) + return 'dashboard'; + if (pathname.startsWith('/explore') || pathname.startsWith('/chart')) + return 'chart'; + if (pathname.startsWith('/tablemodelview') || pathname.startsWith('/dataset')) + return 'dataset'; + if (pathname === '/' || pathname.startsWith('/superset/welcome')) + return 'home'; + return 'unknown'; +}; + +const readSqlLabTab = (): PageContext['sqlLab'] => { + const tab = tryCall(() => (core as any).sqlLab?.getCurrentTab?.()); + return tab ? { tabId: tab.id, title: tab.title } : undefined; +}; + +const readPageType = (pathname: string): PageType => { + const fromNav = tryCall(() => (core as any).navigation?.getPageType?.()); + return (fromNav as PageType | undefined) ?? inferPageType(pathname); +}; + +/** + * Subscribe to page changes. Uses navigation.onDidChangePage when available, + * falls back to popstate for hosts without the navigation namespace. + * Returns a cleanup function. + */ +export const subscribeToPageChanges = (onChange: () => void): (() => void) => { + const nav = tryCall(() => (core as any).navigation); + if (nav?.onDidChangePage) { + const sub = nav.onDidChangePage(onChange); + return () => sub.dispose(); + } + window.addEventListener('popstate', onChange); + return () => window.removeEventListener('popstate', onChange); +}; + +export const getPageContext = (): PageContext => { + const { pathname, href } = + typeof window !== 'undefined' + ? window.location + : { pathname: '', href: '' }; + + return { + pageType: readPageType(pathname), + dashboard: tryCall(() => (core as any).dashboard?.getCurrentDashboard?.()), + chart: tryCall(() => (core as any).explore?.getCurrentChart?.()), + dataset: tryCall(() => (core as any).dataset?.getCurrentDataset?.()), + sqlLab: readSqlLabTab(), + href, + }; +}; diff --git a/extensions/chat/src/index.tsx b/extensions/chat/src/index.tsx new file mode 100644 index 00000000000..688a5bf283a --- /dev/null +++ b/extensions/chat/src/index.tsx @@ -0,0 +1,30 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Module Federation entry. The host loads `./index` and invokes the factory; + * the side effect below registers the view + commands. The host's loader + * intercepts registerView calls to collect disposables for deactivation, so + * returning the master Disposable here is also captured by the test harness + * for direct assertion. + */ + +import { activate } from './activate'; + +export const disposable = activate(); diff --git a/extensions/chat/src/state.ts b/extensions/chat/src/state.ts new file mode 100644 index 00000000000..5b4a00780d7 --- /dev/null +++ b/extensions/chat/src/state.ts @@ -0,0 +1,57 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Module-scoped open/closed state plus a tiny emitter the UI subscribes to. + * + * Lives entirely inside the extension — never reaches into the host store. + * Reset on dispose so re-activation starts cleanly. + */ + +export type OpenStateListener = (open: boolean) => void; + +let open = false; +const listeners = new Set<OpenStateListener>(); + +export const isOpen = (): boolean => open; + +export const setOpen = (next: boolean): void => { + if (next === open) return; + open = next; + listeners.forEach(fn => { + try { + fn(open); + } catch { + // A listener throwing must not block other listeners or flip our state back. + } + }); +}; + +export const subscribe = (fn: OpenStateListener): (() => void) => { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; +}; + +/** Drains listeners and resets state. Called from the master Disposable. */ +export const resetState = (): void => { + open = false; + listeners.clear(); +}; diff --git a/extensions/chat/src/streaming/mockStream.ts b/extensions/chat/src/streaming/mockStream.ts new file mode 100644 index 00000000000..a72cf0d6435 --- /dev/null +++ b/extensions/chat/src/streaming/mockStream.ts @@ -0,0 +1,73 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Mock streaming reply used to validate stream teardown semantics. + * + * The reference chatbot is environment-validation only — there is no LLM. + * This iterator yields canned tokens on a timer and exits cleanly when its + * AbortSignal is fired. Disposal of the extension aborts any in-flight + * controller, which is the contract that proves async cancellation works. + */ + +const TICK_MS = 40; + +const buildReply = (prompt: string): string => { + const trimmed = prompt.trim(); + if (!trimmed) { + return 'Reference chatbot online. Send a message to validate streaming.'; + } + return ( + `[reference-chatbot] received "${trimmed}". ` + + 'Streaming token-by-token to validate cancellation and teardown.' + ); +}; + +const sleep = (ms: number, signal: AbortSignal): Promise<void> => + new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new DOMException('aborted', 'AbortError')); + return; + } + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + reject(new DOMException('aborted', 'AbortError')); + }; + signal.addEventListener('abort', onAbort, { once: true }); + }); + +export async function* streamReply( + prompt: string, + signal: AbortSignal, +): AsyncIterableIterator<string> { + const tokens = buildReply(prompt).split(/(\s+)/); + for (const token of tokens) { + if (signal.aborted) return; + try { + await sleep(TICK_MS, signal); + } catch { + return; + } + yield token; + } +} diff --git a/extensions/chat/src/streaming/registry.ts b/extensions/chat/src/streaming/registry.ts new file mode 100644 index 00000000000..fca0f2fdbc8 --- /dev/null +++ b/extensions/chat/src/streaming/registry.ts @@ -0,0 +1,46 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Module-scoped registry of in-flight stream AbortControllers. + * + * Lets the master Disposable abort any running stream even when the panel + * is unmounted by a route change or by re-activation of the extension. + */ + +const active = new Set<AbortController>(); + +export const registerActiveController = (c: AbortController): void => { + active.add(c); +}; + +export const unregisterActiveController = (c: AbortController): void => { + active.delete(c); +}; + +export const abortAllActiveControllers = (): void => { + active.forEach(c => { + try { + c.abort(); + } catch { + // ignore — abort() should not throw, but stay defensive. + } + }); + active.clear(); +}; diff --git a/extensions/chat/tsconfig.json b/extensions/chat/tsconfig.json new file mode 100644 index 00000000000..0edb6a5ac3f --- /dev/null +++ b/extensions/chat/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2019", + "module": "esnext", + "moduleResolution": "node", + "jsx": "react", + "strict": true, + "noImplicitAny": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "lib": ["dom", "es2019"] + }, + "include": ["src"] +} diff --git a/extensions/chat/tsconfig.test.json b/extensions/chat/tsconfig.test.json new file mode 100644 index 00000000000..3c04af57683 --- /dev/null +++ b/extensions/chat/tsconfig.test.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@apache-superset/core": ["src/__tests__/sdkMock.ts"] + }, + "typeRoots": [ + "./node_modules/@types", + "../../superset-frontend/node_modules/@types" + ], + "types": ["jest", "node"] + }, + "include": ["src"] +} diff --git a/extensions/chat/webpack.config.js b/extensions/chat/webpack.config.js new file mode 100644 index 00000000000..13a615d6596 --- /dev/null +++ b/extensions/chat/webpack.config.js @@ -0,0 +1,70 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +const path = require('path'); +const { ModuleFederationPlugin } = require('webpack').container; +const packageConfig = require('./package.json'); +const extensionConfig = require('./extension.json'); + +module.exports = (env, argv) => { + const isProd = argv.mode === 'production'; + + return { + entry: isProd ? {} : './src/index.tsx', + mode: isProd ? 'production' : 'development', + devtool: isProd ? false : 'eval-cheap-module-source-map', + devServer: { + port: 3030, + headers: { 'Access-Control-Allow-Origin': '*' }, + }, + output: { + clean: true, + filename: isProd ? undefined : '[name].[contenthash].js', + chunkFilename: '[name].[contenthash].js', + path: path.resolve(__dirname, 'dist'), + publicPath: `/api/v1/extensions/${extensionConfig.publisher}/${extensionConfig.name}/`, + }, + resolve: { extensions: ['.ts', '.tsx', '.js', '.jsx'] }, + externalsType: 'window', + externals: { '@apache-superset/core': 'superset' }, + module: { + rules: [ + { test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/ }, + ], + }, + plugins: [ + new ModuleFederationPlugin({ + name: 'apacheSuperset_referenceChatbot', + filename: 'remoteEntry.[contenthash].js', + exposes: { './index': './src/index.tsx' }, + shared: { + react: { + singleton: true, + requiredVersion: packageConfig.peerDependencies.react, + import: false, + }, + 'react-dom': { + singleton: true, + requiredVersion: packageConfig.peerDependencies['react-dom'], + import: false, + }, + }, + }), + ], + }; +};
