diff --git a/.dockerignore b/.dockerignore index 191381ee..e93f3b7f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1 +1,3 @@ -.git \ No newline at end of file +.git +assets.json +node_modules \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 34cb59ac..05dfa81b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 1.0.0-beta.11 [UNRELEASED] +### Added +- An AI agent +- A `bridge.time.now()` api for getting the current server time +### Fixed +- An issue where context menues wouldn't be properly placed when running in a web browser +- An issue where time displays in the rundown didn't sync with the server time +- The clipboard isn't available in insecure contexts +- Updated dependencies + ## 1.0.0-beta.10 ### Added - Human/AI readable descriptors for types diff --git a/Dockerfile b/Dockerfile index 46c070d1..4f4a8705 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,19 @@ # The first stage will # build the app into /app -FROM node:14.16.0-alpine3.10 +FROM node:24-trixie + +# RUN apk add --update --no-cache python3 make g++ + WORKDIR /app COPY package*.json ./ +COPY plugins/* ./plugins/ +COPY scripts/* ./scripts/ RUN npm ci COPY . ./ + RUN npm run build CMD ["npm", "start"] @@ -16,7 +22,7 @@ CMD ["npm", "start"] # to force-squash the history # and prevent any tokens # from leaking out -FROM node:14.16.0-alpine3.10 +FROM node:24-trixie WORKDIR /app COPY --from=0 /app /app diff --git a/README.md b/README.md index 3532e21e..01ff19b1 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) [![REUSE status](https://api.reuse.software/badge/github.com/svt/bridge)](https://api.reuse.software/info/github.com/svt/bridge) -Playout control software that can be customized to fit your needs. +AI-powered playout control software that can be customized to fit your needs. Developed for CasparCG but can control anything that supports OSC. ![Screenshot](/media/screenshot.png) @@ -46,6 +46,7 @@ The roadmap is available on Notion - CasparCG library, playout and templates - LTC timecode triggers - Keyboard triggers +- Built in AI-agent ## Community plugins - [CRON - triggers based on the time of day](https://github.com/axelboberg/bridge-plugin-cron) diff --git a/api/browser/clipboard.js b/api/browser/clipboard.js index 3fae06a7..dd64e2a4 100644 --- a/api/browser/clipboard.js +++ b/api/browser/clipboard.js @@ -7,6 +7,8 @@ const DIController = require('../../shared/DIController') const InvalidArgumentError = require('../error/InvalidArgumentError') class Clipboard { + #copiedContent + /** * Write a string into the clipboard * @param { String } str A string to write @@ -17,7 +19,17 @@ class Clipboard { throw new InvalidArgumentError('Provided text is not a string and cannot be written to the clipboard') } - return navigator.clipboard.writeText(str) + if (navigator.clipboard) { + return navigator.clipboard.writeText(str) + } + + /* + Fall back to using an internal + property as store if navigator.clipboard + is not available (it isn't in insecure contexts) + */ + this.#copiedContent = str + return Promise.resolve(true) } /** @@ -27,7 +39,10 @@ class Clipboard { * @returns { Promise. } */ readText () { - return navigator.clipboard.readText() + if (navigator.clipboard) { + return navigator.clipboard.readText() + } + return Promise.resolve(this.#copiedContent) } /** diff --git a/api/browser/ui/contextMenu.js b/api/browser/ui/contextMenu.js index bcd97d01..7a96a325 100644 --- a/api/browser/ui/contextMenu.js +++ b/api/browser/ui/contextMenu.js @@ -103,6 +103,35 @@ class UIContextMenu { this.#openedAt = Date.now() this.#props.Events.emitLocally('ui.contextMenu.open', spec, opts) } + + /** + * Get the event position for use + * when opening a context menu + * @param { PointerEvent } e + * @returns + */ + getPositionFromEvent (e) { + if (Object.prototype.hasOwnProperty.call(e.nativeEvent, 'pointerType')) { + throw new InvalidArgumentError('Provided event is not of type PointerEvent') + } + + /* + Find the root element, either an encapsulating + iframe or the body and use its position + as offset for the event + */ + let rootEl = e?.target?.ownerDocument?.defaultView?.frameElement + if (!rootEl) { + rootEl = document.body + } + + const bounds = rootEl.getBoundingClientRect() + + return { + x: e.clientX + bounds.x, + y: e.clientY + bounds.y + } + } } DIController.main.register('UIContextMenu', UIContextMenu, [ diff --git a/api/time.js b/api/time.js index 24ba6ffc..a828a1d7 100644 --- a/api/time.js +++ b/api/time.js @@ -4,9 +4,15 @@ const DIController = require('../shared/DIController') +const SERVER_TIME_TTL_MS = 10000 + class Time { #props + #serverTime + #serverTimeUpdatedAt + #serverTimePromise + constructor (props) { this.#props = props } @@ -30,10 +36,65 @@ class Time { submitFrame (id, frame) { return this.#props.Commands.executeRawCommand('time.submitFrame', id, frame) } + + /** + * Update the record of + * the current server time + * + * This will set the #serverTime and #serverTimeUpdatedAt + * properties, allowing the client to return the correct time + * accordingly + * + * @returns { Promise. } + */ + async #updateServerTime () { + /* + Skip updating if it's already trying + to update to avoid multiple requests + */ + if (this.#serverTimePromise) { + return this.#serverTimePromise + } + + /* + Resolve immediately if the local + time hasn't become outdated + */ + if (this.#serverTimeUpdatedAt && (Date.now() - this.#serverTimeUpdatedAt) < SERVER_TIME_TTL_MS) { + return Promise.resolve() + } + + this.#serverTimePromise = new Promise((resolve, reject) => { + const start = Date.now() + this.#props.Commands.executeCommand('time.getServerTime') + .then(now => { + const roundtripDur = Date.now() - start + this.#serverTime = now + Math.round(roundtripDur / 2) + this.#serverTimeUpdatedAt = Date.now() + resolve() + }) + .catch(err => { + this.#serverTimeUpdatedAt = Date.now() + reject(err) + }) + .finally(() => { + this.#serverTimePromise = undefined + }) + }) + return this.#serverTimePromise + } + + /** + * Get the current time according to the server, + * compensating for latency and clock drift + * @returns { Promise. } + */ + async now (id) { + await this.#updateServerTime() + return this.#serverTime + (Date.now() - this.#serverTimeUpdatedAt) + } } DIController.main.register('Time', Time, [ - 'State', - 'Events', 'Commands' ]) diff --git a/api/time.unit.test.js b/api/time.unit.test.js new file mode 100644 index 00000000..3c7a25a6 --- /dev/null +++ b/api/time.unit.test.js @@ -0,0 +1,24 @@ +require('./time') + +const DIController = require('../shared/DIController') + +let time +beforeAll(() => { + time = DIController.main.instantiate('Time', { + Commands: { + executeCommand: command => { + if (command === 'time.getServerTime') { + return Promise.resolve(Date.now()) + } + }, + executeRawCommand: () => {} + } + }) +}) + +test('get the server time', async () => { + const value = await time.now() + const now = Date.now() + expect(value).toBeGreaterThanOrEqual(now - 100) + expect(value).toBeLessThan(now + 100) +}) diff --git a/app/bridge.css b/app/bridge.css index 746e0bf5..6e3967b0 100644 --- a/app/bridge.css +++ b/app/bridge.css @@ -145,6 +145,7 @@ h3, .u-heading--3 { } h4, .u-heading--4 { + font-size: 1em; margin: 0.2em 0; } diff --git a/app/components/ContextMenuBoundary/index.jsx b/app/components/ContextMenuBoundary/index.jsx index bdb8f55f..063e9ec2 100644 --- a/app/components/ContextMenuBoundary/index.jsx +++ b/app/components/ContextMenuBoundary/index.jsx @@ -22,20 +22,6 @@ function isNumber (x) { return typeof x === 'number' && !Number.isNaN(x) } -function getScreenCoordinates () { - return { - x: window.screenLeft, - y: window.screenTop - } -} - -function convertToPageCoordinates (ctxX, ctxY, screenX, screenY) { - return { - x: ctxX - screenX, - y: ctxY - screenY - } -} - function sanitizeItemSpec (spec) { const out = {} for (const property of ALLOWED_SPEC_PROPERTIES) { @@ -90,12 +76,9 @@ export function ContextMenuBoundary ({ children }) { return } - const screenCoords = getScreenCoordinates() - const pageCoords = convertToPageCoordinates(opts.x, opts.y, screenCoords.x, screenCoords.y) - setContextPos({ - x: Math.max(pageCoords.x, 0), - y: Math.max(pageCoords.y, 0) + x: Math.max(opts.x, 0), + y: Math.max(opts.y, 0) }) setOpts(opts) diff --git a/docker-compose.yml b/docker-compose.yml index 08ff66b4..ff363ff4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,8 @@ services: - .:/app environment: - NODE_ENV=development - - PORT=3000 + - HTTP_PORT=3000 + - HTTP_ADDR=0.0.0.0 - APP_DATA_BASE_PATH=../data ports: - 3000:3000 diff --git a/docs/api/README.md b/docs/api/README.md index 6d81f50c..4cf562e2 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -21,6 +21,7 @@ Bridge provides a JavaScript api for use in plugins and their widgets. - [Messages](#messages) - [UI](#ui) - [Context menus](#context-menus) +- [Time](#time) ## Getting started The api is available for plugins and widgets running in either the main process or browser processes of Bridge and can be included as follows. The module will be provided by Bridge at runtime. @@ -634,9 +635,15 @@ const spec = [ window.addEventListener('contextmenu', e => { bridge.ui.contextMenu.open(spec, { - x: e.screenX, // Required - y: e.screenY, // Required - searchable: true // Optional, defaults to false, whether or not to show a search field and allow the user to search for any items in the menu + searchable: true // Optional, defaults to false, whether or not to show a search field and allow the user to search for any items in the menu, + + // Coordinates must be required either by + // 1. Auto populating coordinates (recommended) + ...bridge.ui.contextMenu.getPositionFromEvent(e), + + // 2. Manually defining coordinates + y: 10, + x: 10 }) }) ``` @@ -662,4 +669,15 @@ Close any opened context menus, this does not need to be called as a response to ```javascript import bridge from 'bridge' bridge.ui.contextMenu.close() +``` + +## Time + +### `bridge.time.now(): Promise.` +Get the current server time, consider this a replacement for Date.now(). +This function compensates for roundtrip latency and local clock drift. + +```javascript +import bridge from 'bridge' +const now = await bridge.time.now() ``` \ No newline at end of file diff --git a/lib/api/STime.js b/lib/api/STime.js index 8f6f3e02..d17ea68b 100644 --- a/lib/api/STime.js +++ b/lib/api/STime.js @@ -50,6 +50,7 @@ class STime extends DIBase { } #setup () { + this.props.SCommands.registerAsyncCommand('time.getServerTime', this.getServerTime.bind(this)) this.props.SCommands.registerAsyncCommand('time.registerClock', this.registerClock.bind(this)) this.props.SCommands.registerAsyncCommand('time.getAllClocks', this.getAllClocks.bind(this)) this.props.SCommands.registerAsyncCommand('time.removeClock', this.removeClock.bind(this)) @@ -126,6 +127,18 @@ class STime extends DIBase { this.#emitClocksChangedEvent() } + /** + * Get the current + * server time + * + * @returns { + * now: number + * } + */ + getServerTime () { + return Date.now() + } + /** * Submit a clock frame * for a specific clock diff --git a/lib/config.js b/lib/config.js index f00fc81b..665c0c10 100644 --- a/lib/config.js +++ b/lib/config.js @@ -3,5 +3,6 @@ // SPDX-License-Identifier: MIT exports.defaults = { - HTTP_PORT: 5544 + HTTP_PORT: process.env.HTTP_PORT || 5544, + HTTP_ADDR: process.env.HTTP_ADDR || 'localhost' } diff --git a/lib/init-common.js b/lib/init-common.js index bd39539c..9bd97de9 100644 --- a/lib/init-common.js +++ b/lib/init-common.js @@ -81,7 +81,7 @@ const DEFAULT_HTTP_PORT = config.defaults.HTTP_PORT logger.warn('Failed to restore user defaults, maybe it\'s the first time the application is running', err) } finally { UserDefaults.apply({ - httpPort: process.env.PORT || json?.httpPort || DEFAULT_HTTP_PORT + httpPort: process.env.HTTP_PORT || json?.httpPort || DEFAULT_HTTP_PORT }) } })() diff --git a/lib/init-node.js b/lib/init-node.js index 99e8c093..3859130f 100644 --- a/lib/init-node.js +++ b/lib/init-node.js @@ -6,18 +6,11 @@ const fs = require('node:fs') const paths = require('./paths') const UserDefaults = require('./UserDefaults') -const WorkspaceRegistry = require('./WorkspaceRegistry') const Logger = require('./Logger') const logger = new Logger({ name: 'init-node' }) -/** -* The minimum threshold after creation -* that a workspace can be teared down, -* assuming no connections -* @type { Number } -*/ -const WORKSPACE_TEARDOWN_MIN_THRESHOLD_MS = 20000 +logger.info('Initializing') /* Write the user defaults-state to disk @@ -39,35 +32,3 @@ process.on('SIGINT', () => { writeUserDeafults() process.exit(0) }) - -/* -Setup listeners for new workspaces -in order to remove any dangling -references -*/ -WorkspaceRegistry.getInstance().on('add', async workspace => { - const creationTimeStamp = Date.now() - - function conditionalTeardownWorkspaces () { - /* - Make sure that we've given clients - a timeframe to connect before - terminating the workspace - */ - if (Date.now() - creationTimeStamp < WORKSPACE_TEARDOWN_MIN_THRESHOLD_MS) { - return - } - - if (Object.keys(workspace?.state?.data?._connections || {}).length > 0) { - return - } - - logger.debug('Tearing down workspace', workspace.id) - WorkspaceRegistry.getInstance().delete(workspace.id) - workspace.teardown() - } - - workspace.on('cleanup', async () => { - conditionalTeardownWorkspaces() - }) -}) diff --git a/lib/plugin/context.js b/lib/plugin/context.js index bd90e19d..676557f8 100644 --- a/lib/plugin/context.js +++ b/lib/plugin/context.js @@ -46,21 +46,7 @@ function factory (workspace, manifest) { * in a worker thread */ function activate () { - ctx.worker = new Worker(WORKER_ENTRYPOINT_PATH, { - workerData: { - manifest - } - }) - - ctx.worker.on('exit', () => { - /* - Clear all commands registered - by this context - */ - ctx.workspace.api.commands.removeAllByOwner(ctx.id) - }) - - ctx.worker.on('error', err => { + function handleError (err) { /* Show worker errors as messages in the UI */ @@ -72,6 +58,29 @@ function factory (workspace, manifest) { return } throw err + } + + try { + ctx.worker = new Worker(WORKER_ENTRYPOINT_PATH, { + workerData: { + manifest + } + }) + } catch (err) { + logger.error('Fatal error when setting up', manifest?.name) + handleError(err) + } + + ctx.worker.on('exit', () => { + /* + Clear all commands registered + by this context + */ + ctx.workspace.api.commands.removeAllByOwner(ctx.id) + }) + + ctx.worker.on('error', err => { + handleError(err) }) /* diff --git a/lib/schemas/setting.schema.json b/lib/schemas/setting.schema.json index 3de21786..cd021ce2 100644 --- a/lib/schemas/setting.schema.json +++ b/lib/schemas/setting.schema.json @@ -7,7 +7,7 @@ "properties": { "type": { "type": "string", - "enum": ["boolean", "theme", "number", "string", "button", "frame", "select", "segmented", "list"] + "enum": ["boolean", "theme", "number", "string", "button", "frame", "select", "segmented", "list", "warning"] }, "bind": { "type": "string" diff --git a/lib/server.js b/lib/server.js index 3885cb75..1bb2776b 100644 --- a/lib/server.js +++ b/lib/server.js @@ -33,11 +33,16 @@ MUST be declared AFTER initialization as UserDefaults would otherwise be blank */ const HTTP_PORT = UserDefaults.data.httpPort || config.defaults.HTTP_PORT -const HTTP_BIND_ADDR = UserDefaults.data.httpBindToAll ? '0.0.0.0' : 'localhost' +const HTTP_BIND_ADDR = UserDefaults.data.httpBindToAll ? '0.0.0.0' : config.defaults.HTTP_ADDR const app = express() app.disable('x-powered-by') + +app.use((req, res, next) => { + logger.debug(`${req.method} ${req.path}`) + next() +}) app.use(express.static(path.join(__dirname, '../public'))) app.use(express.static(path.join(__dirname, '../dist'))) @@ -46,8 +51,12 @@ app.use(express.static(path.join(__dirname, '../dist'))) * the main http server * @type { HttpError.Server } */ -const server = app.listen(HTTP_PORT, HTTP_BIND_ADDR, () => { - logger.info('Listening on port', HTTP_PORT) +const server = app.listen(HTTP_PORT, HTTP_BIND_ADDR, err => { + if (err) { + logger.error(err) + } else { + logger.info('Listening on', `${HTTP_BIND_ADDR}:${HTTP_PORT}`) + } }) ;(function () { diff --git a/lib/template.json b/lib/template.json index 88892e61..cd1e02d2 100644 --- a/lib/template.json +++ b/lib/template.json @@ -31,6 +31,26 @@ "component": "bridge.plugins.inspector" } } + }, + "2": { + "title": "Agent", + "component": "bridge.internals.grid", + "layout": { + "agent": { "x": 0, "y": 0, "w": 6, "h": 12 }, + "rundown": { "x": 6, "y": 0, "w": 12, "h": 12 }, + "inspector": { "x": 18, "y": 0, "w": 6, "h": 12 } + }, + "children": { + "agent": { + "component": "bridge.plugins.agent" + }, + "rundown": { + "component": "bridge.plugins.rundown" + }, + "inspector": { + "component": "bridge.plugins.inspector" + } + } } } } diff --git a/package-lock.json b/package-lock.json index c446341e..e24dedb4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7558,9 +7558,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -12080,16 +12080,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -13041,16 +13031,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" - } - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -13704,9 +13684,9 @@ } }, "node_modules/tar": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz", - "integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==", + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz", + "integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -13750,16 +13730,15 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.16", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", - "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", + "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", - "serialize-javascript": "^6.0.2", "terser": "^5.31.1" }, "engines": { diff --git a/plugins/agent/README.md b/plugins/agent/README.md new file mode 100644 index 00000000..78220e4c --- /dev/null +++ b/plugins/agent/README.md @@ -0,0 +1,2 @@ +# Agent plugin +An MCP client, server and host for using agents within Bridge diff --git a/plugins/agent/app/App.jsx b/plugins/agent/app/App.jsx new file mode 100644 index 00000000..1efaebeb --- /dev/null +++ b/plugins/agent/app/App.jsx @@ -0,0 +1,23 @@ +import React from 'react' +import './style.css' + +import { Chat } from './views/Chat' +import { Settings } from './views/Settings' + +export default function () { + const [view, setView] = React.useState() + + React.useEffect(() => { + const params = new URLSearchParams(window.location.search) + setView(params.get('path')) + }, []) + + switch (view) { + case 'chat': + return + case 'settings': + return + default: + return <> + } +} \ No newline at end of file diff --git a/plugins/agent/app/assets/icons/error.svg b/plugins/agent/app/assets/icons/error.svg new file mode 100644 index 00000000..cf5ae74e --- /dev/null +++ b/plugins/agent/app/assets/icons/error.svg @@ -0,0 +1,11 @@ + + + icons/message/error + + + + + + + + \ No newline at end of file diff --git a/plugins/agent/app/assets/icons/index.jsx b/plugins/agent/app/assets/icons/index.jsx new file mode 100644 index 00000000..f6508b38 --- /dev/null +++ b/plugins/agent/app/assets/icons/index.jsx @@ -0,0 +1,13 @@ +import reload from './reload.svg' +import send from './send.svg' + +import success from './success.svg' +import error from './error.svg' + +export default { + reload, + send, + + success, + error +} \ No newline at end of file diff --git a/plugins/agent/app/assets/icons/reload.svg b/plugins/agent/app/assets/icons/reload.svg new file mode 100644 index 00000000..ae0de5a6 --- /dev/null +++ b/plugins/agent/app/assets/icons/reload.svg @@ -0,0 +1,10 @@ + + + icons/reload + + + + + + + \ No newline at end of file diff --git a/plugins/agent/app/assets/icons/send.svg b/plugins/agent/app/assets/icons/send.svg new file mode 100644 index 00000000..70daafe7 --- /dev/null +++ b/plugins/agent/app/assets/icons/send.svg @@ -0,0 +1,7 @@ + + + icons/send + + + + \ No newline at end of file diff --git a/plugins/agent/app/assets/icons/success.svg b/plugins/agent/app/assets/icons/success.svg new file mode 100644 index 00000000..fefa585c --- /dev/null +++ b/plugins/agent/app/assets/icons/success.svg @@ -0,0 +1,8 @@ + + + icons/message/success + + + + + \ No newline at end of file diff --git a/plugins/agent/app/components/ChatFooter/index.jsx b/plugins/agent/app/components/ChatFooter/index.jsx new file mode 100644 index 00000000..71932bd9 --- /dev/null +++ b/plugins/agent/app/components/ChatFooter/index.jsx @@ -0,0 +1,51 @@ +import React from 'react' +import './style.css' + +import { Icon } from '../Icon' + +export function ChatFooter ({ model, contextUsage = 0, onSend = () => {} }) { + const [input, setInput] = React.useState() + + function handleSendMessage () { + if (!input || input?.length === 0) { + return + } + onSend({ text: input }) + setInput('') + } + + function handleKeyDown (e) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSendMessage() + } + } + + return ( +
+