Skip to content

Commit 07c16e0

Browse files
authored
Add the first version of Bridget, the AI agent (#119)
* Fix an issue that misplaced context menus when running in a web browser Signed-off-by: Axel Boberg <git@axelboberg.se> * Use server time for time displays in the rundown Signed-off-by: Axel Boberg <git@axelboberg.se> * Fix an issue where the clipboard isn't available in a insecure contexts Signed-off-by: Axel Boberg <git@axelboberg.se> * Start preparing for server deployments Signed-off-by: Axel Boberg <git@axelboberg.se> * Catch plugin initialization errors Signed-off-by: Axel Boberg <git@axelboberg.se> * Add a default tab for the new agent mode Signed-off-by: Axel Boberg <git@axelboberg.se> * Allow plugins to use warnings in settings Signed-off-by: Axel Boberg <git@axelboberg.se> * Set a default font size for h4 headings Signed-off-by: Axel Boberg <git@axelboberg.se> * Add the first version of the ai agent Signed-off-by: Axel Boberg <git@axelboberg.se> * Update dependencies Signed-off-by: Axel Boberg <git@axelboberg.se> * Add AI to the feature list Signed-off-by: Axel Boberg <git@axelboberg.se> --------- Signed-off-by: Axel Boberg <git@axelboberg.se>
1 parent 0166bac commit 07c16e0

72 files changed

Lines changed: 7939 additions & 135 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.dockerignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1-
.git
1+
.git
2+
assets.json
3+
node_modules

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Changelog
22

3+
## 1.0.0-beta.11 [UNRELEASED]
4+
### Added
5+
- An AI agent
6+
- A `bridge.time.now()` api for getting the current server time
7+
### Fixed
8+
- An issue where context menues wouldn't be properly placed when running in a web browser
9+
- An issue where time displays in the rundown didn't sync with the server time
10+
- The clipboard isn't available in insecure contexts
11+
- Updated dependencies
12+
313
## 1.0.0-beta.10
414
### Added
515
- Human/AI readable descriptors for types

Dockerfile

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
# The first stage will
22
# build the app into /app
3-
FROM node:14.16.0-alpine3.10
3+
FROM node:24-trixie
4+
5+
# RUN apk add --update --no-cache python3 make g++
6+
47
WORKDIR /app
58

69
COPY package*.json ./
10+
COPY plugins/* ./plugins/
11+
COPY scripts/* ./scripts/
712

813
RUN npm ci
914

1015
COPY . ./
16+
1117
RUN npm run build
1218

1319
CMD ["npm", "start"]
@@ -16,7 +22,7 @@ CMD ["npm", "start"]
1622
# to force-squash the history
1723
# and prevent any tokens
1824
# from leaking out
19-
FROM node:14.16.0-alpine3.10
25+
FROM node:24-trixie
2026
WORKDIR /app
2127

2228
COPY --from=0 /app /app

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
[![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com)
66
[![REUSE status](https://api.reuse.software/badge/github.com/svt/bridge)](https://api.reuse.software/info/github.com/svt/bridge)
77

8-
Playout control software that can be customized to fit your needs.
8+
AI-powered playout control software that can be customized to fit your needs.
99
Developed for CasparCG but can control anything that supports OSC.
1010

1111
![Screenshot](/media/screenshot.png)
@@ -46,6 +46,7 @@ The roadmap is available on Notion
4646
- CasparCG library, playout and templates
4747
- LTC timecode triggers
4848
- Keyboard triggers
49+
- Built in AI-agent
4950

5051
## Community plugins
5152
- [CRON - triggers based on the time of day](https://github.com/axelboberg/bridge-plugin-cron)

api/browser/clipboard.js

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ const DIController = require('../../shared/DIController')
77
const InvalidArgumentError = require('../error/InvalidArgumentError')
88

99
class Clipboard {
10+
#copiedContent
11+
1012
/**
1113
* Write a string into the clipboard
1214
* @param { String } str A string to write
@@ -17,7 +19,17 @@ class Clipboard {
1719
throw new InvalidArgumentError('Provided text is not a string and cannot be written to the clipboard')
1820
}
1921

20-
return navigator.clipboard.writeText(str)
22+
if (navigator.clipboard) {
23+
return navigator.clipboard.writeText(str)
24+
}
25+
26+
/*
27+
Fall back to using an internal
28+
property as store if navigator.clipboard
29+
is not available (it isn't in insecure contexts)
30+
*/
31+
this.#copiedContent = str
32+
return Promise.resolve(true)
2133
}
2234

2335
/**
@@ -27,7 +39,10 @@ class Clipboard {
2739
* @returns { Promise.<string> }
2840
*/
2941
readText () {
30-
return navigator.clipboard.readText()
42+
if (navigator.clipboard) {
43+
return navigator.clipboard.readText()
44+
}
45+
return Promise.resolve(this.#copiedContent)
3146
}
3247

3348
/**

api/browser/ui/contextMenu.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,35 @@ class UIContextMenu {
103103
this.#openedAt = Date.now()
104104
this.#props.Events.emitLocally('ui.contextMenu.open', spec, opts)
105105
}
106+
107+
/**
108+
* Get the event position for use
109+
* when opening a context menu
110+
* @param { PointerEvent } e
111+
* @returns
112+
*/
113+
getPositionFromEvent (e) {
114+
if (Object.prototype.hasOwnProperty.call(e.nativeEvent, 'pointerType')) {
115+
throw new InvalidArgumentError('Provided event is not of type PointerEvent')
116+
}
117+
118+
/*
119+
Find the root element, either an encapsulating
120+
iframe or the body and use its position
121+
as offset for the event
122+
*/
123+
let rootEl = e?.target?.ownerDocument?.defaultView?.frameElement
124+
if (!rootEl) {
125+
rootEl = document.body
126+
}
127+
128+
const bounds = rootEl.getBoundingClientRect()
129+
130+
return {
131+
x: e.clientX + bounds.x,
132+
y: e.clientY + bounds.y
133+
}
134+
}
106135
}
107136

108137
DIController.main.register('UIContextMenu', UIContextMenu, [

api/time.js

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,15 @@
44

55
const DIController = require('../shared/DIController')
66

7+
const SERVER_TIME_TTL_MS = 10000
8+
79
class Time {
810
#props
911

12+
#serverTime
13+
#serverTimeUpdatedAt
14+
#serverTimePromise
15+
1016
constructor (props) {
1117
this.#props = props
1218
}
@@ -30,10 +36,65 @@ class Time {
3036
submitFrame (id, frame) {
3137
return this.#props.Commands.executeRawCommand('time.submitFrame', id, frame)
3238
}
39+
40+
/**
41+
* Update the record of
42+
* the current server time
43+
*
44+
* This will set the #serverTime and #serverTimeUpdatedAt
45+
* properties, allowing the client to return the correct time
46+
* accordingly
47+
*
48+
* @returns { Promise.<void> }
49+
*/
50+
async #updateServerTime () {
51+
/*
52+
Skip updating if it's already trying
53+
to update to avoid multiple requests
54+
*/
55+
if (this.#serverTimePromise) {
56+
return this.#serverTimePromise
57+
}
58+
59+
/*
60+
Resolve immediately if the local
61+
time hasn't become outdated
62+
*/
63+
if (this.#serverTimeUpdatedAt && (Date.now() - this.#serverTimeUpdatedAt) < SERVER_TIME_TTL_MS) {
64+
return Promise.resolve()
65+
}
66+
67+
this.#serverTimePromise = new Promise((resolve, reject) => {
68+
const start = Date.now()
69+
this.#props.Commands.executeCommand('time.getServerTime')
70+
.then(now => {
71+
const roundtripDur = Date.now() - start
72+
this.#serverTime = now + Math.round(roundtripDur / 2)
73+
this.#serverTimeUpdatedAt = Date.now()
74+
resolve()
75+
})
76+
.catch(err => {
77+
this.#serverTimeUpdatedAt = Date.now()
78+
reject(err)
79+
})
80+
.finally(() => {
81+
this.#serverTimePromise = undefined
82+
})
83+
})
84+
return this.#serverTimePromise
85+
}
86+
87+
/**
88+
* Get the current time according to the server,
89+
* compensating for latency and clock drift
90+
* @returns { Promise.<number> }
91+
*/
92+
async now (id) {
93+
await this.#updateServerTime()
94+
return this.#serverTime + (Date.now() - this.#serverTimeUpdatedAt)
95+
}
3396
}
3497

3598
DIController.main.register('Time', Time, [
36-
'State',
37-
'Events',
3899
'Commands'
39100
])

api/time.unit.test.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
require('./time')
2+
3+
const DIController = require('../shared/DIController')
4+
5+
let time
6+
beforeAll(() => {
7+
time = DIController.main.instantiate('Time', {
8+
Commands: {
9+
executeCommand: command => {
10+
if (command === 'time.getServerTime') {
11+
return Promise.resolve(Date.now())
12+
}
13+
},
14+
executeRawCommand: () => {}
15+
}
16+
})
17+
})
18+
19+
test('get the server time', async () => {
20+
const value = await time.now()
21+
const now = Date.now()
22+
expect(value).toBeGreaterThanOrEqual(now - 100)
23+
expect(value).toBeLessThan(now + 100)
24+
})

app/bridge.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ h3, .u-heading--3 {
145145
}
146146

147147
h4, .u-heading--4 {
148+
font-size: 1em;
148149
margin: 0.2em 0;
149150
}
150151

app/components/ContextMenuBoundary/index.jsx

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -22,20 +22,6 @@ function isNumber (x) {
2222
return typeof x === 'number' && !Number.isNaN(x)
2323
}
2424

25-
function getScreenCoordinates () {
26-
return {
27-
x: window.screenLeft,
28-
y: window.screenTop
29-
}
30-
}
31-
32-
function convertToPageCoordinates (ctxX, ctxY, screenX, screenY) {
33-
return {
34-
x: ctxX - screenX,
35-
y: ctxY - screenY
36-
}
37-
}
38-
3925
function sanitizeItemSpec (spec) {
4026
const out = {}
4127
for (const property of ALLOWED_SPEC_PROPERTIES) {
@@ -90,12 +76,9 @@ export function ContextMenuBoundary ({ children }) {
9076
return
9177
}
9278

93-
const screenCoords = getScreenCoordinates()
94-
const pageCoords = convertToPageCoordinates(opts.x, opts.y, screenCoords.x, screenCoords.y)
95-
9679
setContextPos({
97-
x: Math.max(pageCoords.x, 0),
98-
y: Math.max(pageCoords.y, 0)
80+
x: Math.max(opts.x, 0),
81+
y: Math.max(opts.y, 0)
9982
})
10083

10184
setOpts(opts)

0 commit comments

Comments
 (0)