Compare commits
9 Commits
twilio-err
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 10cb94588d | |||
| 98c5d48846 | |||
| c1ab9a4bb5 | |||
| 9493eb6e5e | |||
| c80e595585 | |||
| bed1fa0eb8 | |||
| 2428afd3db | |||
| b53a4197c6 | |||
| 27aa62f950 |
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -33,4 +33,8 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
|
||||
.claude
|
||||
.claude.worktrees/
|
||||
.worktrees/
|
||||
|
||||
docs/learning/
|
||||
docs/plans/
|
||||
33
bun.lock
33
bun.lock
|
|
@ -1,12 +1,13 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "tmp",
|
||||
"dependencies": {
|
||||
"robot3": "./packages/robot3",
|
||||
"hono": "^4.10.4",
|
||||
"openai": "^6.9.0",
|
||||
"robot3": "1.1.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
|
|
@ -18,14 +19,32 @@
|
|||
},
|
||||
},
|
||||
"packages": {
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.6", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w=="],
|
||||
|
||||
"acorn": ["acorn@7.4.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A=="],
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="],
|
||||
|
||||
"commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"hono": ["hono@4.10.6", "", {}, "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g=="],
|
||||
|
|
@ -34,10 +53,20 @@
|
|||
|
||||
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
|
||||
|
||||
"robot3": ["robot3@1.1.1", "", {}, "sha512-kuD0oQg2KUE74FCQ1a5uoRsEJ/bUKrU1D3vnluop9X7LSiGLndejQgjUEcMqJMVzUA836HSXhtY7XNtQiPTCLQ=="],
|
||||
"robot3": ["robot3@file:packages/robot3", { "devDependencies": { "rollup": "^1.21.4", "terser": "^5.16.1" } }],
|
||||
|
||||
"rollup": ["rollup@1.32.1", "", { "dependencies": { "@types/estree": "*", "@types/node": "*", "acorn": "^7.1.0" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-/2HA0Ec70TvQnXdzynFffkjA6XN+1e2pEv/uKS5Ulca40g2L7KuOE3riasHoNVHOsFD5KKZgDsMk1CP3Tw9s+A=="],
|
||||
|
||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||
|
||||
"terser": ["terser@5.46.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"terser/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
"dependencies": {
|
||||
"hono": "^4.10.4",
|
||||
"openai": "^6.9.0",
|
||||
"robot3": "1.1.1"
|
||||
"robot3": "./packages/robot3"
|
||||
},
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
|
|
|
|||
70
packages/robot3/CHANGELOG.md
Normal file
70
packages/robot3/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# robot3
|
||||
|
||||
## 1.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- bcc2995: Improve the type definition for state and invoke functions
|
||||
|
||||
## 1.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 0cf6366: '/logging' is now exported, so you can import it in your dev environment to log state changes.
|
||||
|
||||
```ts
|
||||
import 'robot3/logging';
|
||||
|
||||
import {...} from 'robot3';
|
||||
```
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 950b6fa: Fix syntax error in state function type definition that caused TypeScript compilation failures. The previous change had a missing space in a conditional type expression, breaking type inference for state transitions.
|
||||
|
||||
## 1.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 1d6179a: Fixes types for the state() function.
|
||||
|
||||
## 1.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 4f6fb69: Autocomplete for service.send()
|
||||
|
||||
This makes it so that the event name in `service.send(event)` is inferred from the transitions used to create the machine.
|
||||
|
||||
## 1.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 9fbdbcb: Set the most deeply nested current service to current
|
||||
- 0409089: Documentation for advanced use of 'invoke()'
|
||||
|
||||
## 1.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cc17481: Add debug to package exports
|
||||
|
||||
## 1.0.0
|
||||
|
||||
### Major Changes
|
||||
|
||||
- 52742ab: Call onChange callbacks for immediate states too
|
||||
|
||||
## 0.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fc4806e: Adding an export property to the core package.json for 'import' so that destructured imports work, in addition to the default imports handled by the 'default' property
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- cce2ae6: Drop support for Node 14
|
||||
|
||||
This drops support for Node 14, with it no longer being supported by the LTS in February. Robot might still work in Node 14 but is not tested in our CI.
|
||||
8
packages/robot3/bundlesize.json
Normal file
8
packages/robot3/bundlesize.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"files": [
|
||||
{
|
||||
"path": "./machine.min.js",
|
||||
"maxSize": "1.4 kB"
|
||||
}
|
||||
]
|
||||
}
|
||||
40
packages/robot3/debug.js
Normal file
40
packages/robot3/debug.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { d, invoke } from './machine.js';
|
||||
|
||||
const invokePromiseType = Object.getPrototypeOf(invoke(Promise.resolve()));
|
||||
|
||||
function unknownState(from, state) {
|
||||
throw new Error(`Cannot transition from ${from} to unknown state: ${state}`);
|
||||
}
|
||||
|
||||
d._create = function(current, states) {
|
||||
if(!(current in states)) {
|
||||
throw new Error(`Initial state [${current}] is not a known state.`);
|
||||
}
|
||||
for(let p in states) {
|
||||
let state = states[p];
|
||||
for(let [, candidates] of state.transitions) {
|
||||
for(let {to} of candidates) {
|
||||
if(!(to in states)) {
|
||||
unknownState(p, to);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (invokePromiseType.isPrototypeOf(state)) {
|
||||
let hasErrorFrom = false;
|
||||
for(let [, candidates] of state.transitions) {
|
||||
for(let {from} of candidates) {
|
||||
if (from === 'error') hasErrorFrom = true;
|
||||
}
|
||||
}
|
||||
if(!hasErrorFrom) {
|
||||
console.warn(
|
||||
`When using invoke [current state: ${p}] with Promise-returning function, you need to add 'error' state. Otherwise, robot will hide errors in Promise-returning function`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
d._send = function(eventName, currentStateName) {
|
||||
throw new Error(`No transitions for event ${eventName} from the current state [${currentStateName}]`);
|
||||
};
|
||||
253
packages/robot3/index.d.ts
vendored
Normal file
253
packages/robot3/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
declare module 'robot3' {
|
||||
|
||||
/**
|
||||
* TS Helpers
|
||||
*/
|
||||
type NestedKeys<T> = T extends object
|
||||
? {
|
||||
[P in keyof T]-?: P extends string ? keyof T[P] : never
|
||||
}[keyof T]
|
||||
: never
|
||||
|
||||
type AllStateKeys<T> = NestedKeys<T> | keyof T;
|
||||
|
||||
type MachineStates<S = {}, F extends string = string> = {
|
||||
[K in keyof S]: {
|
||||
final: boolean
|
||||
transitions: Map<string, Transition<F>[]>
|
||||
immediates?: Map<string, Immediate<F>[]>
|
||||
enter?: any
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The debugging object contains an _onEnter method, wich can be set to invoke
|
||||
* this function on every transition.
|
||||
*/
|
||||
export const d: {
|
||||
_onEnter?: OnEnterFunction<Machine<any>>
|
||||
}
|
||||
|
||||
/**
|
||||
* The `createMachine` function creates a state machine. It takes an object of *states* with the key being the state name.
|
||||
* The value is usually *state* but might also be *invoke*.
|
||||
*
|
||||
* @param initial - Creates a machine that has *initial* as it's initial state.
|
||||
* @param states - An object of states, where each key is a state name, and the values are one of *state* or *invoke*.
|
||||
* @param context - A function that returns an object of extended state values. The function can receive an `event` argument.
|
||||
*/
|
||||
export function createMachine<S extends MachineStates<S, F>, C = {}, F extends string = string>(
|
||||
initial: keyof S,
|
||||
states: S,
|
||||
context?: ContextFunction<C>
|
||||
): Machine<S, C, AllStateKeys<S>>
|
||||
/**
|
||||
* The `createMachine` function creates a state machine. It takes an object of *states* with the key being the state name.
|
||||
* The value is usually *state* but might also be *invoke*.
|
||||
*
|
||||
* @param states - An object of states, where each key is a state name, and the values are one of *state* or *invoke*.
|
||||
* @param context - A function that returns an object of extended state values. The function can receive an `event` argument.
|
||||
*/
|
||||
export function createMachine<S extends MachineStates<S, F>, C = {}, F extends string = string>(
|
||||
states: S,
|
||||
context?: ContextFunction<C>
|
||||
): Machine<S, C, AllStateKeys<S>>;
|
||||
|
||||
/**
|
||||
* The `state` function returns a state object. A state can take transitions and immediates as arguments.
|
||||
*
|
||||
* @param args - Any argument needs to be of type Transition or Immediate.
|
||||
*/
|
||||
export function state<T extends ReadonlyArray<Transition<any> | Immediate<any>>>(
|
||||
...args: T
|
||||
): MachineState<ExtractTransitionOrImmediateTypes<T>[number]>;
|
||||
|
||||
/**
|
||||
* A `transition` function is used to move from one state to another.
|
||||
*
|
||||
* @param event - This will give the name of the event that triggers this transition.
|
||||
* @param state - The name of the destination state.
|
||||
* @param args - Any extra argument will be evaluated to check if they are one of Reducer, Guard or Action.
|
||||
*/
|
||||
export function transition<F extends string, C, E>(
|
||||
event: F,
|
||||
state: string,
|
||||
...args: (Reducer<C, E> | Guard<C, E> | Action<C, E>)[]
|
||||
): Transition<F>;
|
||||
|
||||
/**
|
||||
* An `immediate` function is a type of transition that occurs immediately; it doesn't wait for an event to proceed.
|
||||
* This is a state that immediately proceeds to the next.
|
||||
*
|
||||
* @param state - The name of the destination state.
|
||||
* @param args - Any extra argument will be evaluated to check if they are a Reducer or a Guard.
|
||||
*/
|
||||
export function immediate<F extends string, C, E>(
|
||||
state: string,
|
||||
...args: (Reducer<C, E> | Guard<C, E> | Action<C, E>)[]
|
||||
): Transition<F>
|
||||
|
||||
/**
|
||||
* A `guard` is a method that determines if a transition can proceed.
|
||||
* Returning true allows the transition to occur, returning false prevents it from doing so and leaves the state in its current place.
|
||||
*
|
||||
* @param guardFunction A Function that can receive *context* and *event* and will return true or false.
|
||||
*/
|
||||
export function guard<C, E>(guardFunction?: GuardFunction<C, E>): Guard<C, E>
|
||||
|
||||
/**
|
||||
* A `reduce` takes a reducer function for changing the context of the machine. A common use case is to set values coming from form fields.
|
||||
*
|
||||
* @param reduceFunction A Function that can receive *context* and *event* and will return the context.
|
||||
*/
|
||||
export function reduce<C, E>(reduceFunction?: ReduceFunction<C, E>): Reducer<C, E>
|
||||
|
||||
/**
|
||||
* An `action` function takes a function that will be run during a transition. The primary purpose of using action is to perform side-effects.
|
||||
*
|
||||
* @param actionFunction A Function that can receive *context* and *event*. Returned values are discarded.
|
||||
*/
|
||||
export function action<C, E>(actionFunction?: ActionFunction<C, E>): Action<C, E>
|
||||
|
||||
/**
|
||||
* The `interpret` function takes a machine and creates a service that can send events into the machine, changing its states.
|
||||
* A service does not mutate a machine, but rather creates derived machines with the current state set.
|
||||
*
|
||||
* @param machine The state `machine`, created with *createMachine* to create a new service for.
|
||||
* @param onChange A callback that is called when the machine completes a transition. Even if the transition results in returning to the same state, the `onChange` callback is still called.
|
||||
* @param event The `event` can be any object. It is passed to the context function
|
||||
*/
|
||||
export function interpret<M extends Machine, E>(
|
||||
machine: M,
|
||||
onChange?: InterpretOnChangeFunction<typeof machine>,
|
||||
initialContext?: M['context'],
|
||||
event?: { [K in keyof E]: any }
|
||||
): Service<typeof machine>
|
||||
|
||||
/**
|
||||
* The `invoke` is a special type of state that immediately invokes a Promise-returning or Machine-returning function, or another machine.
|
||||
*
|
||||
* @param fn - Promise-returning function
|
||||
* @param args - Any argument needs to be of type Transition or Immediate.
|
||||
*/
|
||||
export function invoke<C, T, E extends {} = any>(fn: (ctx: C, e?: E) => Promise<T>, ...args: (Transition<any> | Immediate<any>)[]): MachineState<any>
|
||||
|
||||
/**
|
||||
* The `invoke` is a special type of state that immediately invokes a Promise-returning or Machine-returning function, or another machine.
|
||||
*
|
||||
* @param fn - Machine-returning function
|
||||
* @param args - Any argument needs to be of type Transition or Immediate.
|
||||
*/
|
||||
export function invoke<C, E extends {} = any, M extends Machine = any>(fn: (ctx: C, e?: E) => M, ...args: (Transition<any> | Immediate<any>)[]): MachineState<any>
|
||||
|
||||
/**
|
||||
* The `invoke` is a special type of state that immediately invokes a Promise-returning or Machine-returning function, or another machine.
|
||||
*
|
||||
* @param machine - Machine
|
||||
* @param args - Any argument needs to be of type Transition or Immediate.
|
||||
*/
|
||||
export function invoke<M extends Machine>(machine: M, ...args: (Transition<any> | Immediate<any>)[]):
|
||||
// fixme: this type isn't 100% correct, as it adds the child's transitions to the parent.
|
||||
MachineState<GetMachineTransitions<M>>
|
||||
|
||||
/* General Types */
|
||||
|
||||
export type ContextFunction<T> = (initialContext: T) => T
|
||||
|
||||
export type GuardFunction<C, E> = (context: C, event: E) => boolean
|
||||
|
||||
export type ActionFunction<C, E> = (context: C, event: E) => unknown
|
||||
|
||||
export type ReduceFunction<C, E> = (context: C, event: E) => C
|
||||
|
||||
export type InterpretOnChangeFunction<T extends Machine> = (
|
||||
service: Service<T>
|
||||
) => void
|
||||
|
||||
export type SendEvent<T extends string = string> = T | { type: T; [key: string]: any }
|
||||
export type SendFunction<T extends string> = (event: SendEvent<T> & {}) => void
|
||||
|
||||
/**
|
||||
* This function is invoked before entering a new state and is bound to the debug
|
||||
* object. It is usable to inspect or log changes.
|
||||
*
|
||||
* @param machine - Machine
|
||||
* @param to - name of the target state
|
||||
* @param state - current state
|
||||
* @param prevState - previous state
|
||||
* @param event - event provoking the state change
|
||||
*/
|
||||
export type OnEnterFunction<M extends Machine<any>> =
|
||||
<C = M['state']>(machine: M, to: string, state: C, prevState: C, event?: SendEvent) => void
|
||||
|
||||
export type Machine<S extends MachineStates<S, F> = {}, C = {}, K = string, F extends string = string> = {
|
||||
context: C
|
||||
current: K
|
||||
states: S
|
||||
state: {
|
||||
name: K
|
||||
value: MachineState<F>
|
||||
}
|
||||
}
|
||||
|
||||
export type Action<C, E> = {
|
||||
fn: ActionFunction<C, E>
|
||||
}
|
||||
|
||||
export type Reducer<C, E> = {
|
||||
fn: ReduceFunction<C, E>
|
||||
}
|
||||
|
||||
export type Guard<C, E> = {
|
||||
fn: GuardFunction<C, E>
|
||||
}
|
||||
|
||||
export interface MachineState<F extends string> {
|
||||
final: boolean
|
||||
transitions: Map<F, Transition<F>[]>
|
||||
immediates?: Map<F, Immediate<F>[]>
|
||||
enter?: any
|
||||
}
|
||||
|
||||
export interface Transition<F extends string> {
|
||||
from: F | null
|
||||
to: string
|
||||
guards: any[]
|
||||
reducers: any[]
|
||||
}
|
||||
|
||||
export interface Service<M extends Machine> {
|
||||
child?: Service<M>
|
||||
machine: M
|
||||
context: M['context']
|
||||
onChange: InterpretOnChangeFunction<M>
|
||||
send: SendFunction<GetMachineTransitions<M>>
|
||||
}
|
||||
|
||||
export type Immediate<F extends string> = Transition<F>;
|
||||
|
||||
// Utilities
|
||||
type IsAny<T> = 0 extends (1 & T) ? true : false;
|
||||
|
||||
// Get state objects from a Machine
|
||||
type GetMachineStateObject<M extends Machine> = M['states'];
|
||||
|
||||
// Create mapped type without the final indexing
|
||||
type GetTransitionsFromStates<S> = {
|
||||
[K in keyof S]: S[K] extends { transitions: Map<string, Array<Transition<infer F>>> }
|
||||
? IsAny<F> extends true
|
||||
? never
|
||||
: F
|
||||
: never
|
||||
}
|
||||
|
||||
type ExtractNonAnyValues<T> = {
|
||||
[K in keyof T]: IsAny<T[K]> extends true ? never : T[K]
|
||||
}[keyof T] & {};
|
||||
|
||||
export type GetMachineTransitions<M extends Machine> =
|
||||
ExtractNonAnyValues<GetTransitionsFromStates<GetMachineStateObject<M>>>;
|
||||
|
||||
type ExtractTransitionOrImmediateTypes<T extends ReadonlyArray<Transition<any> | Immediate<any>>> =
|
||||
{ [K in keyof T]: T[K] extends Transition<infer V> ? V : T[K] extends Immediate<infer V> ? V : never };
|
||||
}
|
||||
17
packages/robot3/logging.js
Normal file
17
packages/robot3/logging.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { d } from './machine.js';
|
||||
|
||||
d._onEnter = function(machine, to, state, prevState, event) {
|
||||
console.log(`Enter state ${to}`);
|
||||
console.groupCollapsed(`Details:`);
|
||||
console.log(`Machine`, machine);
|
||||
console.log(`Current state`, state);
|
||||
console.log(`Previous state`, prevState);
|
||||
|
||||
if (typeof event === "string") {
|
||||
console.log(`Event ${event}`);
|
||||
} else if (typeof event === "object") {
|
||||
console.log(`Event`, event);
|
||||
}
|
||||
|
||||
console.groupEnd();
|
||||
}
|
||||
207
packages/robot3/machine.js
Normal file
207
packages/robot3/machine.js
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
function valueEnumerable(value) {
|
||||
return { enumerable: true, value };
|
||||
}
|
||||
|
||||
function valueEnumerableWritable(value) {
|
||||
return { enumerable: true, writable: true, value };
|
||||
}
|
||||
|
||||
export let d = {};
|
||||
let truthy = () => true;
|
||||
let empty = () => ({});
|
||||
let identity = a => a;
|
||||
let callBoth = (par, fn, self, args) => par.apply(self, args) && fn.apply(self, args);
|
||||
let callForward = (par, fn, self, [a, b]) => fn.call(self, par.call(self, a, b), b);
|
||||
let create = (a, b) => Object.freeze(Object.create(a, b));
|
||||
|
||||
function stack(fns, def, caller) {
|
||||
return fns.reduce((par, fn) => {
|
||||
return function(...args) {
|
||||
return caller(par, fn, this, args);
|
||||
};
|
||||
}, def);
|
||||
}
|
||||
|
||||
function fnType(fn) {
|
||||
return create(this, { fn: valueEnumerable(fn) });
|
||||
}
|
||||
|
||||
let reduceType = {};
|
||||
export let reduce = fnType.bind(reduceType);
|
||||
export let action = fn => reduce((ctx, ev) => !!~fn(ctx, ev) && ctx);
|
||||
|
||||
let guardType = {};
|
||||
export let guard = fnType.bind(guardType);
|
||||
|
||||
function filter(Type, arr) {
|
||||
return arr.filter(value => Type.isPrototypeOf(value));
|
||||
}
|
||||
|
||||
function makeTransition(from, to, ...args) {
|
||||
let guards = stack(filter(guardType, args).map(t => t.fn), truthy, callBoth);
|
||||
let reducers = stack(filter(reduceType, args).map(t => t.fn), identity, callForward);
|
||||
return create(this, {
|
||||
from: valueEnumerable(from),
|
||||
to: valueEnumerable(to),
|
||||
guards: valueEnumerable(guards),
|
||||
reducers: valueEnumerable(reducers)
|
||||
});
|
||||
}
|
||||
|
||||
let transitionType = {};
|
||||
let immediateType = {};
|
||||
export let transition = makeTransition.bind(transitionType);
|
||||
export let immediate = makeTransition.bind(immediateType, null);
|
||||
|
||||
function enterImmediate(machine, service, event) {
|
||||
return transitionTo(service, machine, event, this.immediates) || machine;
|
||||
}
|
||||
|
||||
function transitionsToMap(transitions) {
|
||||
let m = new Map();
|
||||
for(let t of transitions) {
|
||||
if(!m.has(t.from)) m.set(t.from, []);
|
||||
m.get(t.from).push(t);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
let stateType = { enter: identity };
|
||||
export function state(...args) {
|
||||
let transitions = filter(transitionType, args);
|
||||
let immediates = filter(immediateType, args);
|
||||
let desc = {
|
||||
final: valueEnumerable(args.length === 0),
|
||||
transitions: valueEnumerable(transitionsToMap(transitions))
|
||||
};
|
||||
if(immediates.length) {
|
||||
desc.immediates = valueEnumerable(immediates);
|
||||
desc.enter = valueEnumerable(enterImmediate);
|
||||
}
|
||||
return create(stateType, desc);
|
||||
}
|
||||
|
||||
let invokeFnType = {
|
||||
enter(machine2, service, event) {
|
||||
let rn = this.fn.call(service, service.context, event)
|
||||
if(machine.isPrototypeOf(rn))
|
||||
return create(invokeMachineType, {
|
||||
machine: valueEnumerable(rn),
|
||||
transitions: valueEnumerable(this.transitions)
|
||||
}).enter(machine2, service, event)
|
||||
rn
|
||||
.then(data => {
|
||||
if (machine2 === service.machine)
|
||||
return service.send({ type: 'done', data });
|
||||
})
|
||||
.catch(error => {
|
||||
if (machine2 === service.machine)
|
||||
return service.send({ type: 'error', error });
|
||||
});
|
||||
return machine2;
|
||||
}
|
||||
};
|
||||
let invokeMachineType = {
|
||||
enter(machine, service, event) {
|
||||
service.child = interpret(this.machine, s => {
|
||||
service.onChange(s);
|
||||
if(service.child == s && s.machine.state.value.final) {
|
||||
delete service.child;
|
||||
service.send({ type: 'done', data: s.context });
|
||||
}
|
||||
}, service.context, event);
|
||||
if(service.child.machine.state.value.final) {
|
||||
let data = service.child.context;
|
||||
delete service.child;
|
||||
return transitionTo(service, machine, { type: 'done', data }, this.transitions.get('done'));
|
||||
}
|
||||
return machine;
|
||||
}
|
||||
};
|
||||
export function invoke(fn, ...transitions) {
|
||||
let t = valueEnumerable(transitionsToMap(transitions));
|
||||
return machine.isPrototypeOf(fn) ?
|
||||
create(invokeMachineType, {
|
||||
machine: valueEnumerable(fn),
|
||||
transitions: t
|
||||
}) :
|
||||
create(invokeFnType, {
|
||||
fn: valueEnumerable(fn),
|
||||
transitions: t
|
||||
});
|
||||
}
|
||||
|
||||
let machine = {
|
||||
get state() {
|
||||
return {
|
||||
name: this.current,
|
||||
value: this.states[this.current]
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export function createMachine(current, states, contextFn = empty) {
|
||||
if(typeof current !== 'string') {
|
||||
contextFn = states || empty;
|
||||
states = current;
|
||||
current = Object.keys(states)[0];
|
||||
}
|
||||
if(d._create) d._create(current, states);
|
||||
return create(machine, {
|
||||
context: valueEnumerable(contextFn),
|
||||
current: valueEnumerable(current),
|
||||
states: valueEnumerable(states)
|
||||
});
|
||||
}
|
||||
|
||||
function transitionTo(service, machine, fromEvent, candidates) {
|
||||
let { context } = service;
|
||||
for(let { to, guards, reducers } of candidates) {
|
||||
if(guards(context, fromEvent)) {
|
||||
service.context = reducers.call(service, context, fromEvent);
|
||||
|
||||
let original = machine.original || machine;
|
||||
let newMachine = create(original, {
|
||||
current: valueEnumerable(to),
|
||||
original: { value: original }
|
||||
});
|
||||
|
||||
if (d._onEnter) d._onEnter(machine, to, service.context, context, fromEvent);
|
||||
let state = newMachine.state.value;
|
||||
service.machine = newMachine;
|
||||
let ret = state.enter(newMachine, service, fromEvent);
|
||||
service.onChange(service);
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function send(service, event) {
|
||||
let eventName = event.type || event;
|
||||
let { machine } = service;
|
||||
let { value: state, name: currentStateName } = machine.state;
|
||||
|
||||
if(state.transitions.has(eventName)) {
|
||||
return transitionTo(service, machine, event, state.transitions.get(eventName)) || machine;
|
||||
} else {
|
||||
if(d._send) d._send(eventName, currentStateName);
|
||||
}
|
||||
return machine;
|
||||
}
|
||||
|
||||
let service = {
|
||||
send(event) {
|
||||
send(this, event);
|
||||
}
|
||||
};
|
||||
|
||||
export function interpret(machine, onChange, initialContext, event) {
|
||||
let s = Object.create(service, {
|
||||
machine: valueEnumerableWritable(machine),
|
||||
context: valueEnumerableWritable(machine.context(initialContext, event)),
|
||||
onChange: valueEnumerable(onChange)
|
||||
});
|
||||
s.send = s.send.bind(s);
|
||||
s.machine = s.machine.state.value.enter(s.machine, s, event);
|
||||
return s;
|
||||
}
|
||||
121
packages/robot3/package.json
Normal file
121
packages/robot3/package.json
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
{
|
||||
"name": "robot3",
|
||||
"version": "1.3.0",
|
||||
"description": "A functional, immutable Finite State Machine library",
|
||||
"main": "dist/machine.js",
|
||||
"types": "./index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"require": "./dist/machine.js",
|
||||
"import": "./machine.js",
|
||||
"default": "./machine.js",
|
||||
"types": "./index.d.ts"
|
||||
},
|
||||
"./debug": {
|
||||
"require": "./dist/debug.js",
|
||||
"import": "./debug.js",
|
||||
"default": "./debug.js"
|
||||
},
|
||||
"./logging": {
|
||||
"require": "./dist/logging.js",
|
||||
"import": "./logging.js",
|
||||
"default": "./logging.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/",
|
||||
"debug.js",
|
||||
"logging.js",
|
||||
"machine.js",
|
||||
"index.d.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"minify": "wireit",
|
||||
"bundlesize": "wireit",
|
||||
"server": "wireit",
|
||||
"test": "wireit",
|
||||
"test:types": "wireit",
|
||||
"test:browser": "wireit",
|
||||
"build:cjs": "wireit",
|
||||
"build": "wireit"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/matthewp/robot.git"
|
||||
},
|
||||
"keywords": [
|
||||
"Finite State Machine"
|
||||
],
|
||||
"author": "Matthew Phillips <matthew@matthewphillips.info>",
|
||||
"license": "BSD-2-Clause",
|
||||
"bugs": {
|
||||
"url": "https://github.com/matthewp/robot/issues"
|
||||
},
|
||||
"homepage": "https://github.com/matthewp/robot#readme",
|
||||
"devDependencies": {
|
||||
"rollup": "^1.21.4",
|
||||
"terser": "^5.16.1"
|
||||
},
|
||||
"wireit": {
|
||||
"minify": {
|
||||
"command": "terser machine.js -m --module -o machine.min.js",
|
||||
"files": [
|
||||
"machine.js"
|
||||
],
|
||||
"output": [
|
||||
"machine.min.js"
|
||||
]
|
||||
},
|
||||
"bundlesize": {
|
||||
"command": "bundlesize --config bundlesize.json",
|
||||
"dependencies": [
|
||||
"minify"
|
||||
]
|
||||
},
|
||||
"server": {
|
||||
"command": "ws -p 1965",
|
||||
"service": {
|
||||
"readyWhen": {
|
||||
"lineMatches": "Listening on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"dependencies": [
|
||||
"test:types",
|
||||
"test:browser"
|
||||
]
|
||||
},
|
||||
"test:types": {
|
||||
"command": "tsc -p test/types/tsconfig.json",
|
||||
"files": []
|
||||
},
|
||||
"test:browser": {
|
||||
"command": "node-qunit-puppeteer http://localhost:1965/test/test.html 10000",
|
||||
"dependencies": [
|
||||
"server"
|
||||
],
|
||||
"files": [
|
||||
"machine.js"
|
||||
],
|
||||
"output": []
|
||||
},
|
||||
"build:cjs": {
|
||||
"command": "rollup -d dist -f cjs machine.js debug.js logging.js",
|
||||
"files": [
|
||||
"machine.js",
|
||||
"debug.js",
|
||||
"logging.js"
|
||||
],
|
||||
"output": [
|
||||
"dist"
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"dependencies": [
|
||||
"build:cjs",
|
||||
"minify"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
21
packages/robot3/test/test-action.js
Normal file
21
packages/robot3/test/test-action.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { createMachine, action, interpret, state, transition } from '../machine.js';
|
||||
|
||||
QUnit.module('Action', () => {
|
||||
QUnit.test('Can be used to do side-effects', assert => {
|
||||
let count = 0;
|
||||
let orig = {};
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
transition('ping', 'two',
|
||||
action(() => count++)
|
||||
)
|
||||
),
|
||||
two: state()
|
||||
}, () => orig);
|
||||
let service = interpret(machine, () => {});
|
||||
service.send('ping');
|
||||
|
||||
assert.equal(service.context, orig, 'context stays the same');
|
||||
assert.equal(count, 1, 'side-effect performed');
|
||||
});
|
||||
});
|
||||
53
packages/robot3/test/test-debug.js
Normal file
53
packages/robot3/test/test-debug.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { createMachine, interpret, state, transition, reduce, d} from '../machine.js';
|
||||
|
||||
QUnit.module('robot/debug');
|
||||
|
||||
QUnit.test('Errors for transitions to states that don\'t exist', assert => {
|
||||
try {
|
||||
createMachine({
|
||||
one: state(
|
||||
transition('go', 'two')
|
||||
)
|
||||
});
|
||||
} catch(e) {
|
||||
assert.ok(/unknown state/.test(e.message), 'Gets an error about unknown states');
|
||||
}
|
||||
});
|
||||
|
||||
QUnit.test('Does not error for transitions to states when state does exist', assert => {
|
||||
try {
|
||||
createMachine({
|
||||
one: state(
|
||||
transition('go', 'two')
|
||||
),
|
||||
two: state()
|
||||
});
|
||||
assert.ok(true, 'Created a valid machine!');
|
||||
} catch(e) {
|
||||
assert.ok(false, 'Should not have errored');
|
||||
}
|
||||
});
|
||||
|
||||
QUnit.test('Errors if an invalid initial state is provided', assert => {
|
||||
try {
|
||||
createMachine('oops', {
|
||||
one: state()
|
||||
});
|
||||
assert.ok(false, 'should have failed');
|
||||
} catch(e) {
|
||||
assert.ok(true, 'it is errored');
|
||||
}
|
||||
});
|
||||
|
||||
QUnit.test('Errors when no transitions for event from the current state', assert => {
|
||||
try {
|
||||
const machine = createMachine('one', {
|
||||
one: state(),
|
||||
});
|
||||
const { send } = interpret(machine, () => {});
|
||||
send('go');
|
||||
assert.ok(false, 'should have failed');
|
||||
} catch(e) {
|
||||
assert.ok(true, 'it is errored');
|
||||
}
|
||||
});
|
||||
50
packages/robot3/test/test-guard.js
Normal file
50
packages/robot3/test/test-guard.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { createMachine, interpret, guard, state, transition } from '../machine.js';
|
||||
|
||||
QUnit.module('Guards', hooks => {
|
||||
QUnit.test('Can prevent changing states', assert => {
|
||||
let canProceed = false;
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
transition('ping', 'two', guard(() => canProceed))
|
||||
),
|
||||
two: state()
|
||||
});
|
||||
let service = interpret(machine, service => {});
|
||||
service.send('ping');
|
||||
assert.equal(service.machine.current, 'one');
|
||||
canProceed = true;
|
||||
service.send('ping');
|
||||
assert.equal(service.machine.current, 'two');
|
||||
});
|
||||
|
||||
QUnit.test('If there are multiple guards, any returning false prevents a transition', assert => {
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
transition('ping', 'two',
|
||||
guard(() => false),
|
||||
guard(() => true)
|
||||
)
|
||||
),
|
||||
two: state()
|
||||
});
|
||||
let service = interpret(machine, service => {});
|
||||
service.send('ping');
|
||||
assert.equal(service.machine.current, 'one');
|
||||
});
|
||||
|
||||
QUnit.test('Guards are passed the event', assert => {
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
transition('ping', 'two',
|
||||
guard((ctx, ev) => ev.canProceed)
|
||||
)
|
||||
),
|
||||
two: state()
|
||||
});
|
||||
let service = interpret(machine, () => {});
|
||||
service.send({ type: 'ping' });
|
||||
assert.equal(service.machine.current, 'one', 'still in the initial state');
|
||||
service.send({ type: 'ping', canProceed: true });
|
||||
assert.equal(service.machine.current, 'two', 'now moved');
|
||||
});
|
||||
});
|
||||
52
packages/robot3/test/test-immediate.js
Normal file
52
packages/robot3/test/test-immediate.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { createMachine, guard, interpret, immediate, state, transition } from '../machine.js';
|
||||
|
||||
QUnit.module('Immediate', hooks => {
|
||||
QUnit.test('Will immediately transition', assert => {
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
transition('ping', 'two')
|
||||
),
|
||||
two: state(
|
||||
immediate('three')
|
||||
),
|
||||
three: state()
|
||||
});
|
||||
let service = interpret(machine, () => {});
|
||||
service.send('ping');
|
||||
assert.equal(service.machine.current, 'three');
|
||||
});
|
||||
|
||||
QUnit.test('Will not reject state when a guard fails', assert => {
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
transition('ping', 'two')
|
||||
),
|
||||
two: state(
|
||||
immediate('three', guard(() => false)),
|
||||
transition('next', 'three')
|
||||
),
|
||||
three: state()
|
||||
});
|
||||
let service = interpret(machine, () => {});
|
||||
service.send('ping');
|
||||
assert.equal(service.machine.current, 'two');
|
||||
service.send('next');
|
||||
assert.equal(service.machine.current, 'three');
|
||||
});
|
||||
|
||||
QUnit.test('Can immediately transitions past 2 states', assert => {
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
immediate('two')
|
||||
),
|
||||
two: state(
|
||||
immediate('three')
|
||||
),
|
||||
three: state()
|
||||
});
|
||||
|
||||
let service = interpret(machine, () => {});
|
||||
assert.equal(service.machine.current, 'three', 'transitioned to 3');
|
||||
assert.ok(service.machine.state.value.final, 'in the final state');
|
||||
});
|
||||
});
|
||||
387
packages/robot3/test/test-invoke.js
Normal file
387
packages/robot3/test/test-invoke.js
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
import { createMachine, immediate, interpret, invoke, reduce, state, state as final, transition } from '../machine.js';
|
||||
|
||||
QUnit.module('Invoke', hooks => {
|
||||
QUnit.module('Promise');
|
||||
|
||||
QUnit.test('Goes to the "done" event when complete', async assert => {
|
||||
let machine = createMachine({
|
||||
one: state(transition('click', 'two')),
|
||||
two: invoke(() => Promise.resolve(13),
|
||||
transition('done', 'three',
|
||||
reduce((ctx, ev) => ({ ...ctx, age: ev.data }))
|
||||
)
|
||||
),
|
||||
three: state()
|
||||
}, () => ({age: 0}));
|
||||
|
||||
let service = interpret(machine, () => {});
|
||||
service.send('click');
|
||||
await Promise.resolve();
|
||||
assert.equal(service.context.age, 13, 'Invoked');
|
||||
assert.equal(service.machine.current, 'three', 'now in the next state');
|
||||
});
|
||||
|
||||
QUnit.test('Goes to the "error" event when there is an error', async assert => {
|
||||
let machine = createMachine({
|
||||
one: state(transition('click', 'two')),
|
||||
two: invoke(() => Promise.reject(new Error('oh no')),
|
||||
transition('error', 'three',
|
||||
reduce((ctx, ev) => ({ ...ctx, error: ev.error }))
|
||||
)
|
||||
),
|
||||
three: state()
|
||||
}, () => ({age: 0}));
|
||||
|
||||
let service = interpret(machine, () => {});
|
||||
service.send('click');
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
assert.equal(service.context.error.message, 'oh no', 'Got the right error');
|
||||
});
|
||||
|
||||
QUnit.test('The initial state can be an invoke', async assert => {
|
||||
let machine = createMachine({
|
||||
one: invoke(() => Promise.resolve(2),
|
||||
transition('done', 'two', reduce((ctx, ev) => ({...ctx, age: ev.data})))
|
||||
),
|
||||
two: state()
|
||||
}, () => ({ age: 0 }));
|
||||
|
||||
let service = interpret(machine, () => {});
|
||||
await Promise.resolve();
|
||||
assert.equal(service.context.age, 2, 'Invoked immediately');
|
||||
assert.equal(service.machine.current, 'two', 'in the new state');
|
||||
});
|
||||
|
||||
QUnit.test('Should not fire "done" event when state changes', async assert => {
|
||||
const wait = ms => () => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
let machine = createMachine({
|
||||
one: state(transition('click', 'two')),
|
||||
two: invoke(wait(10),
|
||||
transition('done', 'one'),
|
||||
transition('click', 'three')
|
||||
),
|
||||
three: state(
|
||||
transition('done', 'error'),
|
||||
),
|
||||
error: state(),
|
||||
});
|
||||
|
||||
let service = interpret(machine, () => { });
|
||||
service.send('click');
|
||||
service.send('click');
|
||||
await wait(15)()
|
||||
assert.equal(service.machine.current, 'three', 'now in the next state');
|
||||
});
|
||||
|
||||
QUnit.test('Should fire "done" when context changes', async assert => {
|
||||
const wait = ms => () => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
let machine = createMachine({
|
||||
one: state(transition('click', 'two')),
|
||||
two: invoke(wait(10),
|
||||
transition('done', 'three'),
|
||||
transition('click', 'two', reduce((ctx) => ({ value: ctx.value + 1 })))
|
||||
),
|
||||
three: state(),
|
||||
error: state(),
|
||||
}, () => ({ value: 0 }));
|
||||
|
||||
let service = interpret(machine, () => { });
|
||||
service.send('click');
|
||||
service.send('click');
|
||||
service.send('click');
|
||||
await wait(15)()
|
||||
assert.equal(service.context.value, 2, 'value should be 2');
|
||||
assert.equal(service.machine.current, 'three', 'now in the correct state');
|
||||
});
|
||||
|
||||
QUnit.module('Machine');
|
||||
|
||||
QUnit.test('Can invoke a child machine', async assert => {
|
||||
assert.expect(4);
|
||||
let one = createMachine({
|
||||
nestedOne: state(
|
||||
transition('go', 'nestedTwo')
|
||||
),
|
||||
nestedTwo: final()
|
||||
});
|
||||
let two = createMachine({
|
||||
one: state(
|
||||
transition('go', 'two')
|
||||
),
|
||||
two: invoke(one,
|
||||
transition('done', 'three')
|
||||
),
|
||||
three: final()
|
||||
});
|
||||
let c = 0;
|
||||
let service = interpret(two, thisService => {
|
||||
switch(c) {
|
||||
case 0:
|
||||
assert.equal(service.machine.current, 'two');
|
||||
break;
|
||||
case 1:
|
||||
assert.notEqual(thisService, service, 'second time a different service');
|
||||
break;
|
||||
case 2:
|
||||
assert.equal(service.machine.current, 'three', 'now in three state');
|
||||
break;
|
||||
}
|
||||
c++;
|
||||
});
|
||||
service.send('go');
|
||||
service.child.send('go');
|
||||
assert.equal(c, 3, 'there were 3 transitions');
|
||||
});
|
||||
|
||||
QUnit.test('Can invoke a dynamic child machine', async assert => {
|
||||
assert.expect(10);
|
||||
let dynamicMachines = [
|
||||
createMachine({
|
||||
nestedOne: state(
|
||||
transition('go', 'nestedTwo')
|
||||
),
|
||||
nestedTwo: final()
|
||||
}),
|
||||
createMachine({
|
||||
nestedThree: state(
|
||||
transition('go', 'nestedFour')
|
||||
),
|
||||
nestedFour: final()
|
||||
})
|
||||
]
|
||||
|
||||
let root = createMachine({
|
||||
one: state(
|
||||
transition('go', 'two')
|
||||
),
|
||||
two: invoke(() => dynamicMachines[0],
|
||||
transition('done', 'three')
|
||||
),
|
||||
three: state(
|
||||
transition('go', 'four')
|
||||
),
|
||||
four: invoke(() => dynamicMachines[1],
|
||||
transition('done', 'five')
|
||||
),
|
||||
five: final()
|
||||
});
|
||||
let c = 0;
|
||||
let service = interpret(root, thisService => {
|
||||
switch(c) {
|
||||
case 0:
|
||||
assert.equal(service.machine.current, 'two');
|
||||
break;
|
||||
case 1:
|
||||
assert.notEqual(thisService, service, 'second time a different service');
|
||||
assert.equal(thisService.machine.current, 'nestedTwo');
|
||||
break;
|
||||
case 2:
|
||||
assert.equal(thisService, service, 'equal service');
|
||||
assert.equal(service.machine.current, 'three', 'now in three state');
|
||||
break;
|
||||
case 3:
|
||||
assert.equal(service.machine.current, 'four');
|
||||
break;
|
||||
case 4:
|
||||
assert.notEqual(thisService, service, 'third time a different service');
|
||||
assert.equal(thisService.machine.current, 'nestedFour');
|
||||
break;
|
||||
case 5:
|
||||
assert.equal(service.machine.current, 'five', 'now in five state');
|
||||
break;
|
||||
}
|
||||
c++;
|
||||
});
|
||||
service.send('go');
|
||||
service.child.send('go');
|
||||
service.send('go');
|
||||
service.child.send('go');
|
||||
assert.equal(c, 6, 'there were 6 transitions');
|
||||
});
|
||||
|
||||
QUnit.test('Child machines receive events from their parents', async assert => {
|
||||
const action = fn =>
|
||||
reduce((ctx, ev) => {
|
||||
fn(ctx, ev);
|
||||
return ctx;
|
||||
});
|
||||
|
||||
const wait = ms => () => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
const child = createMachine({
|
||||
init: state(
|
||||
immediate('waiting',
|
||||
action(ctx => {
|
||||
ctx.stuff.push(1);
|
||||
})
|
||||
)
|
||||
),
|
||||
waiting: invoke(
|
||||
wait(50),
|
||||
transition('done', 'fin',
|
||||
action(ctx => {
|
||||
ctx.stuff.push(2);
|
||||
})
|
||||
)
|
||||
),
|
||||
fin: state()
|
||||
}, ctx => ctx);
|
||||
|
||||
const machine = createMachine(
|
||||
{
|
||||
idle: state(transition("next", "child")),
|
||||
child: invoke(child, transition("done", "end")),
|
||||
end: state()
|
||||
},
|
||||
() => ({ stuff: [] })
|
||||
);
|
||||
|
||||
let service = interpret(machine, () => {});
|
||||
service.send('next');
|
||||
|
||||
await wait(50)();
|
||||
|
||||
assert.deepEqual(service.context.stuff, [1, 2]);
|
||||
});
|
||||
|
||||
QUnit.test('Service does not have a child when not in an invoked state', assert => {
|
||||
const child = createMachine({
|
||||
nestedOne: state(
|
||||
transition('next', 'nestedTwo')
|
||||
),
|
||||
nestedTwo: state()
|
||||
});
|
||||
const parent = createMachine({
|
||||
one: invoke(child,
|
||||
transition('done', 'two')
|
||||
),
|
||||
two: state()
|
||||
});
|
||||
|
||||
let service = interpret(parent, () => {});
|
||||
assert.ok(service.child, 'there is a child service');
|
||||
|
||||
service.child.send('next');
|
||||
assert.notOk(service.child, 'No longer a child');
|
||||
});
|
||||
|
||||
QUnit.test('Multi level nested machines resolve in correct order', async assert => {
|
||||
assert.expect(18);
|
||||
|
||||
const four = createMachine({
|
||||
init: state(
|
||||
transition('START', 'start'),
|
||||
),
|
||||
start: state(
|
||||
transition('DONE', 'done'),
|
||||
),
|
||||
done: state(),
|
||||
})
|
||||
|
||||
const three = createMachine({
|
||||
init: state(
|
||||
transition('START', 'start'),
|
||||
),
|
||||
start: invoke(four,
|
||||
transition('done', 'done'),
|
||||
),
|
||||
done: state(),
|
||||
})
|
||||
|
||||
const two = createMachine({
|
||||
init: state(
|
||||
transition('START', 'start'),
|
||||
),
|
||||
start: invoke(three,
|
||||
transition('done', 'done'),
|
||||
),
|
||||
done: state(),
|
||||
})
|
||||
|
||||
const one = createMachine({
|
||||
init: state(
|
||||
transition('START', 'start'),
|
||||
),
|
||||
start: invoke(two,
|
||||
transition('done', 'done'),
|
||||
),
|
||||
done: state(),
|
||||
})
|
||||
|
||||
let c = 0;
|
||||
let service = interpret(one, thisService => {
|
||||
switch (c) {
|
||||
case 0:
|
||||
assert.equal(service.machine.current, 'start', 'initial state');
|
||||
break;
|
||||
case 1:
|
||||
assert.notEqual(thisService.machine.states, service.machine.states, 'second time a different service');
|
||||
assert.ok(service.child, 'has child');
|
||||
assert.equal(service.child.machine.current, 'start');
|
||||
break;
|
||||
case 2:
|
||||
assert.ok(service.child.child, 'has grand child');
|
||||
assert.equal(service.child.machine.current, 'start');
|
||||
assert.equal(service.child.child.machine.current, 'start');
|
||||
break;
|
||||
case 3:
|
||||
assert.ok(service.child.child.child, 'has grand grand child');
|
||||
assert.equal(service.child.child.machine.current, 'start');
|
||||
assert.equal(service.child.child.child.machine.current, 'start');
|
||||
break;
|
||||
case 4:
|
||||
assert.equal(service.child.child.child.machine.current, 'done');
|
||||
break;
|
||||
case 5:
|
||||
assert.equal(service.child.child.machine.current, 'done');
|
||||
assert.equal(service.child.child.child, undefined, 'child is removed when resolved');
|
||||
break;
|
||||
case 6:
|
||||
assert.equal(service.child.machine.current, 'done');
|
||||
assert.equal(service.child.child, undefined, 'child is removed when resolved');
|
||||
break;
|
||||
case 7:
|
||||
assert.equal(service.machine.current, 'done');
|
||||
assert.equal(service.child, undefined, 'child is removed when resolved');
|
||||
break;
|
||||
}
|
||||
c++;
|
||||
});
|
||||
service.send('START') // machine one
|
||||
service.child.send('START') // machine two
|
||||
service.child.child.send('START') // machine tree
|
||||
service.child.child.child.send('START') // machine four
|
||||
service.child.child.child.send('DONE') // machine four
|
||||
assert.equal(c, 8, 'there were 6 transitions');
|
||||
});
|
||||
|
||||
QUnit.test('Invoking a machine that immediately finishes', async assert => {
|
||||
assert.expect(3);
|
||||
const expectations = [ 'nestedTwo', 'three', 'three' ];
|
||||
|
||||
const child = createMachine({
|
||||
nestedOne: state(
|
||||
immediate('nestedTwo')
|
||||
),
|
||||
nestedTwo: final()
|
||||
});
|
||||
|
||||
const parent = createMachine({
|
||||
one: state(
|
||||
transition('next', 'two')
|
||||
),
|
||||
two: invoke(child,
|
||||
transition('done', 'three')
|
||||
),
|
||||
three: final()
|
||||
});
|
||||
|
||||
let service = interpret(parent, s => {
|
||||
assert.equal(s.machine.current, expectations.shift());
|
||||
});
|
||||
|
||||
service.send('next');
|
||||
});
|
||||
});
|
||||
27
packages/robot3/test/test-logging.js
Normal file
27
packages/robot3/test/test-logging.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { createMachine, interpret, state, transition, reduce, d} from '../machine.js';
|
||||
|
||||
QUnit.module('robot/logging');
|
||||
|
||||
QUnit.test('Calls the onEnter function if the state is changed', assert => {
|
||||
const machine = createMachine({
|
||||
one: state(
|
||||
transition('go', 'two', reduce((ctx) => (
|
||||
{ ...ctx, x: 1 }
|
||||
)))
|
||||
),
|
||||
two: state()
|
||||
}, () => ({x: 0, y: 0}));
|
||||
|
||||
const service = interpret(machine, () => {});
|
||||
const enterFN = (m, to, state, prevState, event) => {
|
||||
assert.deepEqual(m, machine, 'Machines equal');
|
||||
assert.deepEqual(state, {x:1, y:0}, 'Changed state passed.')
|
||||
assert.deepEqual(prevState, {x:0, y:0}, 'Previous state passed.')
|
||||
assert.equal(to, 'two', 'To state passed.')
|
||||
assert.equal(event, 'go', 'Send event passed.')
|
||||
}
|
||||
|
||||
d._onEnter = enterFN;
|
||||
|
||||
service.send('go');
|
||||
});
|
||||
53
packages/robot3/test/test-reduce.js
Normal file
53
packages/robot3/test/test-reduce.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { createMachine, interpret, reduce, state, transition } from '../machine.js';
|
||||
|
||||
QUnit.module('Reduce', () => {
|
||||
QUnit.test('Basic state change', assert => {
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
transition('ping', 'two',
|
||||
reduce((ctx) => ({ ...ctx, one: 1 })),
|
||||
reduce((ctx) => ({ ...ctx, two: 2 }))
|
||||
)
|
||||
),
|
||||
two: state()
|
||||
});
|
||||
let service = interpret(machine, () => {});
|
||||
service.send('ping');
|
||||
|
||||
let { one, two } = service.context;
|
||||
assert.equal(one, 1, 'first reducer ran');
|
||||
assert.equal(two, 2, 'second reducer ran');
|
||||
});
|
||||
|
||||
QUnit.test('If no reducers, the context remains', assert => {
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
transition('go', 'two')
|
||||
),
|
||||
two: state()
|
||||
}, () => ({ one: 1, two: 2 }));
|
||||
|
||||
let service = interpret(machine, () => {});
|
||||
service.send('go');
|
||||
assert.deepEqual(service.context, { one: 1, two: 2 }, 'context remains');
|
||||
});
|
||||
|
||||
QUnit.test('Event is the second argument', assert => {
|
||||
assert.expect(2);
|
||||
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
transition('go', 'two',
|
||||
reduce(function(ctx, ev) {
|
||||
assert.equal(ev, 'go');
|
||||
return { ...ctx, worked: true };
|
||||
})
|
||||
)
|
||||
),
|
||||
two: state()
|
||||
});
|
||||
let service = interpret(machine, () => {});
|
||||
service.send('go');
|
||||
assert.equal(service.context.worked, true, 'changed the context');
|
||||
});
|
||||
});
|
||||
77
packages/robot3/test/test-state.js
Normal file
77
packages/robot3/test/test-state.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { createMachine, interpret, invoke, reduce, state, transition } from '../machine.js';
|
||||
|
||||
QUnit.module('States', hooks => {
|
||||
QUnit.test('Basic state change', assert => {
|
||||
assert.expect(5);
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
transition('ping', 'two')
|
||||
),
|
||||
two: state(
|
||||
transition('pong', 'one')
|
||||
)
|
||||
});
|
||||
let service = interpret(machine, service => {
|
||||
assert.ok(true, 'Callback called');
|
||||
});
|
||||
assert.equal(service.machine.current, 'one');
|
||||
service.send('ping');
|
||||
assert.equal(service.machine.current, 'two');
|
||||
service.send('pong');
|
||||
assert.equal(service.machine.current, 'one');
|
||||
});
|
||||
|
||||
QUnit.test('Data can be passed into the initial context', assert => {
|
||||
let machine = createMachine({
|
||||
one: state()
|
||||
}, ev => ({ foo: ev.foo }));
|
||||
|
||||
let service = interpret(machine, () => {}, {
|
||||
foo: 'bar'
|
||||
});
|
||||
|
||||
assert.equal(service.context.foo, 'bar', 'works!');
|
||||
});
|
||||
|
||||
QUnit.test('First argument sets the initial state', assert => {
|
||||
let machine = createMachine('two', {
|
||||
one: state(transition('next', 'two')),
|
||||
two: state(transition('next', 'three')),
|
||||
three: state()
|
||||
});
|
||||
|
||||
let service = interpret(machine, () => {});
|
||||
assert.equal(service.machine.current, 'two', 'in the initial state');
|
||||
|
||||
machine = createMachine('two', {
|
||||
one: state(transition('next', 'two')),
|
||||
two: state(),
|
||||
});
|
||||
service = interpret(machine, () => {});
|
||||
assert.equal(service.machine.current, 'two', 'in the initial state');
|
||||
assert.equal(service.machine.state.value.final, true, 'in the final state');
|
||||
});
|
||||
|
||||
QUnit.test('Child machines receive the event used to invoke them', assert => {
|
||||
let child = createMachine({
|
||||
final: state()
|
||||
}, (ctx, ev) => ({ count: ev.count }));
|
||||
let parent = createMachine({
|
||||
start: state(
|
||||
transition('next', 'next')
|
||||
),
|
||||
next: invoke(child,
|
||||
transition('done', 'end',
|
||||
reduce((ctx, ev) => ({
|
||||
...ctx,
|
||||
...ev.data
|
||||
}))
|
||||
)
|
||||
),
|
||||
end: state()
|
||||
});
|
||||
let service = interpret(parent, () => {});
|
||||
service.send({ type: 'next', count: 14 });
|
||||
assert.equal(service.context.count, 14, 'event sent through');
|
||||
});
|
||||
});
|
||||
12
packages/robot3/test/test.html
Normal file
12
packages/robot3/test/test.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<title>Robot tests</title>
|
||||
<link rel="stylesheet" href="https://code.jquery.com/qunit/qunit-2.9.2.css">
|
||||
|
||||
<div id="qunit"></div>
|
||||
<div id="qunit-fixture"></div>
|
||||
<script src="https://code.jquery.com/qunit/qunit-2.9.2.js"></script>
|
||||
<script type="module" src="../debug.js"></script>
|
||||
<script type="module" src="./test.js"></script>
|
||||
7
packages/robot3/test/test.js
Normal file
7
packages/robot3/test/test.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import './test-state.js';
|
||||
import './test-invoke.js';
|
||||
import './test-guard.js';
|
||||
import './test-immediate.js';
|
||||
import './test-reduce.js';
|
||||
import './test-action.js';
|
||||
import './test-debug.js';
|
||||
90
packages/robot3/test/types/send.ts
Normal file
90
packages/robot3/test/types/send.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import {expectTypeOf} from 'expect-type';
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import {
|
||||
type Service,
|
||||
createMachine,
|
||||
transition,
|
||||
state,
|
||||
invoke, interpret
|
||||
} from 'robot3';
|
||||
|
||||
test('send(event) is typed', () => {
|
||||
const machine = createMachine({
|
||||
one: state(transition('go-two', 'two')),
|
||||
two: state(transition('go-one', 'one')),
|
||||
three: state()
|
||||
});
|
||||
|
||||
type Params = Parameters<Service<typeof machine>['send']>;
|
||||
type EventParam = Params[0];
|
||||
type StringParams = Extract<EventParam, string>;
|
||||
expectTypeOf<StringParams>().toEqualTypeOf<'go-one' | 'go-two'>();
|
||||
|
||||
type ObjectParams = Extract<EventParam, { type: string; }>;
|
||||
expectTypeOf<ObjectParams['type']>().toEqualTypeOf<'go-one' | 'go-two'>();
|
||||
});
|
||||
|
||||
test('types machine with multiple transitions from one state', () => {
|
||||
const machine = createMachine({
|
||||
one: state(transition('go-two', 'two'), transition('go-three', 'three')),
|
||||
two: state(transition('go-one', 'one')),
|
||||
three: state()
|
||||
});
|
||||
|
||||
type Params = Parameters<Service<typeof machine>['send']>;
|
||||
type EventParam = Params[0];
|
||||
type StringParams = Extract<EventParam, string>;
|
||||
expectTypeOf<StringParams>().toEqualTypeOf<'go-one' | 'go-two' | 'go-three'>();
|
||||
|
||||
type ObjectParams = Extract<EventParam, { type: string; }>;
|
||||
expectTypeOf<ObjectParams['type']>().toEqualTypeOf<'go-one' | 'go-two' | 'go-three'>();
|
||||
});
|
||||
|
||||
|
||||
test('types nested machine', () => {
|
||||
const stopwalk = createMachine({
|
||||
walk: state(
|
||||
transition('startBlinking', 'blink'),
|
||||
),
|
||||
blink: state(
|
||||
transition('finishBlinking', 'dontWalk'),
|
||||
),
|
||||
dontWalk: state()
|
||||
});
|
||||
|
||||
const stoplight = createMachine({
|
||||
green: state(
|
||||
transition('next', 'yellow')
|
||||
),
|
||||
yellow: state(
|
||||
transition('next', 'red')
|
||||
),
|
||||
red: invoke(stopwalk,
|
||||
transition('done', 'green')
|
||||
)
|
||||
});
|
||||
|
||||
const s = interpret(stoplight, console.log);
|
||||
|
||||
assert.equal(s.machine.current, 'green')
|
||||
s.send("next")
|
||||
assert.equal(s.machine.current, 'yellow')
|
||||
s.send("next")
|
||||
assert.equal(s.machine.current, 'red')
|
||||
assert.equal(s.child?.machine.current, 'walk')
|
||||
s.child?.send("startBlinking")
|
||||
assert.equal(s.child?.machine.current, 'blink')
|
||||
s.child?.send("finishBlinking")
|
||||
assert.equal(s.child, undefined)
|
||||
assert.equal(s.machine.current, "green")
|
||||
|
||||
type Params = Parameters<Service<typeof stoplight>['send']>;
|
||||
type EventParam = Params[0];
|
||||
type StringParams = Extract<EventParam, string>;
|
||||
expectTypeOf<StringParams>().toEqualTypeOf<'next' | 'startBlinking' | 'finishBlinking'>();
|
||||
|
||||
type ObjectParams = Extract<EventParam, { type: string; }>;
|
||||
expectTypeOf<ObjectParams['type']>().toEqualTypeOf<'next' | 'startBlinking' | 'finishBlinking'>();
|
||||
})
|
||||
|
||||
33
packages/robot3/test/types/tsconfig.json
Normal file
33
packages/robot3/test/types/tsconfig.json
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"include": ["../..", "."],
|
||||
"exclude": ["../../node_modules"],
|
||||
"compilerOptions": {
|
||||
/* Base Options: */
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"target": "es2022",
|
||||
"allowJs": false,
|
||||
"moduleDetection": "force",
|
||||
"isolatedModules": true,
|
||||
"moduleResolution": "nodenext",
|
||||
|
||||
/* Strictness */
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
/* AND if you're building for a library: */
|
||||
"declaration": true,
|
||||
|
||||
/* AND if you're building for a library in a monorepo: */
|
||||
"composite": true,
|
||||
"declarationMap": true,
|
||||
|
||||
/* If NOT transpiling with TypeScript: */
|
||||
"module": "nodenext",
|
||||
"noEmit": true,
|
||||
|
||||
/* If your code runs in the DOM: */
|
||||
"lib": ["es2022", "dom", "dom.iterable"]
|
||||
}
|
||||
}
|
||||
BIN
sounds/stalling/hum.wav
Normal file
BIN
sounds/stalling/hum.wav
Normal file
Binary file not shown.
63
src/agent/personality.md
Normal file
63
src/agent/personality.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# Personality
|
||||
|
||||
You are a 1940s American switchboard operator who only answers questions. You have no ability to connect calls. You always sound like you're running a busy switchboard answering many questions. You are polite, efficient, and a little playful.
|
||||
|
||||
# Environment
|
||||
|
||||
The current time is {{current_datetime}}. The location of the caller is in San Francisco. Specifically, the rotary phone in the kitchen of 2145 Turk Blvd, San Francisco California.
|
||||
|
||||
You are working out of The Telephone Corporation headquarters in downtown San Francisco.
|
||||
|
||||
You are answering questions from a user that is talking to you on an old rotary phone. You don't know which person you are talking to. The residents names are:
|
||||
|
||||
- Corey: Dad born Feb 19th, 1980
|
||||
- Lisa: Mom born Dec 8th, 1979
|
||||
- Lulu: Girl born Oct 4th, 2011
|
||||
- Bee: Girl born Jan 15th, 2015
|
||||
- And Maggie the cat, but she doesn't talk.
|
||||
|
||||
# Capabilities
|
||||
|
||||
_Internet Search_
|
||||
You have access to modern information through your "special connection to the information exchange" (internet search). For example, when someone asks about:
|
||||
|
||||
- Current events, news, or recent happenings
|
||||
- Weather forecasts or current conditions
|
||||
- Sports scores or game schedules
|
||||
- Stock prices or market information
|
||||
- Business hours or store information
|
||||
- Facts you're uncertain about
|
||||
- Anything that requires up-to-date information
|
||||
|
||||
You should use your search capability to get accurate, current information. Think of it as consulting your "information operator network" - just like how you'd transfer calls, but for information instead.
|
||||
|
||||
_Vestaboard Messaging_
|
||||
You can send messages to a Vestaboard display. Just provide the message you want to send or a description of what you'd like displayed and the tool will handle the rest. The user may refer to the vestaboard as "the board" or "the message board."
|
||||
|
||||
_Ending the Call_
|
||||
When the user asks you to hang up, or wants to end the conversation you should call the “end_call” tool without saying goodbye or any other parting words.
|
||||
|
||||
# Tone
|
||||
|
||||
Speak in a fast, cheerful, slightly nasal cadence with short, snappy sentences. Avoid modern slang. Reference the technology of the era—switchboards, lines, operators, long-distance, "checking the wires," "consulting the information exchange"—when adding color. Keep responses under three sentences unless more detail is needed.
|
||||
|
||||
When you bring back o search for information, you might say things like:
|
||||
|
||||
- “Here is what I found.”
|
||||
- “Thanks for holding, I found this.”
|
||||
- “Sorry, That took awhile. Here is what I found“
|
||||
|
||||
# Goal
|
||||
|
||||
Your goal is to answer the user's question directly and efficiently, while maintaining your switchboard operator persona. Always consider whether a search would provide better, more current information than what you already know.
|
||||
|
||||
# Guardrails
|
||||
|
||||
- Do not connect real calls
|
||||
- Do not assume the person's gender, you don't know it!
|
||||
- Avoid modern slang
|
||||
- Stay in character as a 1940s switchboard operator
|
||||
- Do not end your sentences with a question
|
||||
- Use simple and brief statements
|
||||
- Actively use search capabilities when questions involve current information, recent events, or facts you're unsure about
|
||||
- Frame your searches in period-appropriate language
|
||||
114
src/phone.ts
114
src/phone.ts
|
|
@ -32,6 +32,7 @@ type PhoneContext = {
|
|||
ringer: GPIO.Output
|
||||
agentId: string
|
||||
agentKey: string
|
||||
dialFailureReason?: string
|
||||
}
|
||||
|
||||
type PhoneService = Service<typeof phoneMachine>
|
||||
|
|
@ -77,7 +78,7 @@ const listenForPhoneEvents = (
|
|||
|
||||
rotaryInUse.onChange((event) => {
|
||||
if (event.value === 0) {
|
||||
phoneService.send({ type: "dial-start" })
|
||||
phoneService.send({ type: "dial-start" } as any)
|
||||
} else {
|
||||
phoneService.send({ type: "dial-stop" })
|
||||
}
|
||||
|
|
@ -110,7 +111,7 @@ const startBaresip = async (phoneService: PhoneService, hook: GPIO.Input, ringer
|
|||
|
||||
baresip.callEstablished.on(({ contact }) => {
|
||||
log(`🐻 call established with ${contact}`)
|
||||
phoneService.send({ type: "answered" })
|
||||
phoneService.send({ type: "answered" } as any)
|
||||
})
|
||||
|
||||
baresip.hungUp.on(() => {
|
||||
|
|
@ -118,19 +119,25 @@ const startBaresip = async (phoneService: PhoneService, hook: GPIO.Input, ringer
|
|||
phoneService.send({ type: "remote-hang-up" })
|
||||
})
|
||||
|
||||
baresip.dialFailed.on(({ reason }) => {
|
||||
log.error("🐻 dial failed:", reason)
|
||||
phoneService.send({ type: "dial-failed", reason } as any)
|
||||
})
|
||||
|
||||
baresip.connect().catch((error) => {
|
||||
log.error("🐻 connection error:", error)
|
||||
phoneService.send({ type: "error", message: error.message })
|
||||
})
|
||||
|
||||
baresip.error.on(async ({ message }) => {
|
||||
log.error("🐻 error:", message)
|
||||
phoneService.send({ type: "error", message })
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await ring(ringer, 500)
|
||||
await sleep(250)
|
||||
}
|
||||
process.exit(1)
|
||||
baresip.error.on(async ({ message, statusCode, reason }) => {
|
||||
const errorMessage = statusCode ? `Registration failed: ${statusCode} ${reason}` : message
|
||||
|
||||
log.error("🐻 error:", errorMessage)
|
||||
// Don't send error to state machine - we're retrying, not giving up
|
||||
|
||||
log("🔄 Retrying registration in 2 minutes...")
|
||||
await sleep(2 * 60 * 1000)
|
||||
baresip.restart()
|
||||
})
|
||||
|
||||
return baresip
|
||||
|
|
@ -165,7 +172,7 @@ const config = (
|
|||
return ctx
|
||||
}
|
||||
|
||||
const startAgent = (service: Service<typeof phoneMachine>, ctx: PhoneContext) => {
|
||||
const startAgent = async (service: Service<typeof phoneMachine>, ctx: PhoneContext, hasDialFailure = false) => {
|
||||
let streamPlayback = player.playStream()
|
||||
|
||||
const agent = new Agent({
|
||||
|
|
@ -177,7 +184,10 @@ const startAgent = (service: Service<typeof phoneMachine>, ctx: PhoneContext) =>
|
|||
})
|
||||
|
||||
handleAgentEvents(service, agent, streamPlayback)
|
||||
const stopListening = startListening(service, agent)
|
||||
|
||||
const stopListening = hasDialFailure
|
||||
? await startListeningAfterDialFailure(agent, ctx.dialFailureReason)
|
||||
: startListening(service, agent)
|
||||
|
||||
ctx.stopAgent = () => {
|
||||
stopListening()
|
||||
|
|
@ -188,7 +198,7 @@ const startAgent = (service: Service<typeof phoneMachine>, ctx: PhoneContext) =>
|
|||
return ctx
|
||||
}
|
||||
|
||||
const startListening = (service: Service<typeof phoneMachine>, agent: Agent) => {
|
||||
function startListening(service: Service<typeof phoneMachine>, agent: Agent) {
|
||||
const abortAgent = new AbortController()
|
||||
|
||||
new Promise<void>(async (resolve) => {
|
||||
|
|
@ -223,7 +233,7 @@ const startListening = (service: Service<typeof phoneMachine>, agent: Agent) =>
|
|||
const rms = Buzz.calculateRMS(chunk)
|
||||
if (rms > 5000) {
|
||||
dialTonePlayback?.stop()
|
||||
service.send({ type: "start-agent" })
|
||||
service.send({ type: "start-agent" } as any)
|
||||
waitingForVoice = false
|
||||
|
||||
backgroundNoisePlayback = await player.play(getSound("background"), { repeat: true })
|
||||
|
|
@ -243,6 +253,42 @@ const startListening = (service: Service<typeof phoneMachine>, agent: Agent) =>
|
|||
return () => abortAgent.abort()
|
||||
}
|
||||
|
||||
async function startListeningAfterDialFailure(agent: Agent, dialFailureReason?: string) {
|
||||
const abortAgent = new AbortController()
|
||||
const recorder = await Buzz.recorder()
|
||||
const listenPlayback = recorder.start()
|
||||
|
||||
const message = getFriendlyErrorMessage(dialFailureReason)
|
||||
agent.events.on((event) => {
|
||||
if (event.type === "connected") agent.sendMessage(`[SYSTEM: ${message}]`)
|
||||
if (event.type === "disconnected") abortAgent.abort()
|
||||
})
|
||||
|
||||
const backgroundNoisePlayback = await player.play(getSound("background"), { repeat: true })
|
||||
await agent.start()
|
||||
|
||||
streamAudioToAgent(agent, listenPlayback, backgroundNoisePlayback, abortAgent.signal)
|
||||
|
||||
return () => abortAgent.abort()
|
||||
}
|
||||
|
||||
async function streamAudioToAgent(
|
||||
agent: Agent,
|
||||
listenPlayback: Buzz.StreamingRecording,
|
||||
backgroundNoisePlayback: Buzz.Playback | undefined,
|
||||
signal: AbortSignal,
|
||||
) {
|
||||
for await (const chunk of listenPlayback.stream()) {
|
||||
if (signal.aborted) {
|
||||
agent.stop()
|
||||
listenPlayback.stop()
|
||||
backgroundNoisePlayback?.stop()
|
||||
break
|
||||
}
|
||||
agent.sendAudio(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAgentEvents = (
|
||||
service: Service<typeof phoneMachine>,
|
||||
agent: Agent,
|
||||
|
|
@ -331,6 +377,24 @@ const answerCall = (ctx: PhoneContext) => {
|
|||
ctx.baresip.accept()
|
||||
}
|
||||
|
||||
const storeDialFailure = (ctx: PhoneContext, event: { reason?: string }) => {
|
||||
ctx.dialFailureReason = event.reason
|
||||
return ctx
|
||||
}
|
||||
|
||||
const clearDialFailure = (ctx: PhoneContext) => {
|
||||
ctx.dialFailureReason = undefined
|
||||
return ctx
|
||||
}
|
||||
|
||||
function getFriendlyErrorMessage(rawReason?: string): string {
|
||||
if (rawReason?.includes("Not registered")) {
|
||||
return "The user's call failed. To fix it, they should contact Corey IRL."
|
||||
}
|
||||
|
||||
return "The user's call failed. Twilio isn't working. To fix it, they should contact Corey IRL."
|
||||
}
|
||||
|
||||
const makeCall = async (ctx: PhoneContext) => {
|
||||
log(`Dialing number: ${ctx.numberDialed}`)
|
||||
if (ctx.numberDialed === 1) {
|
||||
|
|
@ -366,10 +430,13 @@ const stopRinger = (ctx: PhoneContext) => {
|
|||
}
|
||||
|
||||
async function startDialToneAndAgent(this: any, ctx: PhoneContext) {
|
||||
ctx = await startAgent(this, ctx)
|
||||
const hasDialFailure = !!ctx.dialFailureReason
|
||||
ctx = await startAgent(this, ctx, hasDialFailure)
|
||||
|
||||
await dialTonePlayback?.stop()
|
||||
dialTonePlayback = await player.playTone([350, 440], Infinity)
|
||||
if (!hasDialFailure) {
|
||||
await dialTonePlayback?.stop()
|
||||
dialTonePlayback = await player.playTone([350, 440], Infinity)
|
||||
}
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
|
@ -434,18 +501,19 @@ const phoneMachine = createMachine(
|
|||
t("remote-hang-up", "ready"),
|
||||
t("hang-up", "idle", a(hangUp))),
|
||||
ready: invoke(startDialToneAndAgent,
|
||||
t("dial-start", "dialing", a(stopDialTone), r(dialStart), a(stopAgent)),
|
||||
t("hang-up", "idle", a(stopDialTone), a(stopAgent)),
|
||||
t("start-agent", "connectToAgent", a(stopDialTone))),
|
||||
t("dial-start", "dialing", a(stopDialTone), r(dialStart), r(clearDialFailure), a(stopAgent)),
|
||||
t("hang-up", "idle", a(stopDialTone), r(clearDialFailure), a(stopAgent)),
|
||||
t("start-agent", "connectToAgent", a(stopDialTone), r(clearDialFailure))),
|
||||
connectToAgent: state(
|
||||
t("hang-up", "idle", r(stopAgent)),
|
||||
t("remote-hang-up", "ready", r(stopAgent))),
|
||||
t("hang-up", "idle", r(stopAgent), r(clearDialFailure)),
|
||||
t("remote-hang-up", "ready", r(stopAgent), r(clearDialFailure))),
|
||||
dialing: state(
|
||||
t("dial-stop", "outgoing"),
|
||||
t("digit_increment", "dialing", r(digitIncrement)),
|
||||
t("hang-up", "idle")),
|
||||
outgoing: invoke(makeCall,
|
||||
outgoing: invoke(makeCall,
|
||||
t("answered", "connected"),
|
||||
t("dial-failed", "ready", r(storeDialFailure)),
|
||||
t("hang-up", "idle", a(hangUp))),
|
||||
aborted: state(
|
||||
t("hang-up", "idle")),
|
||||
|
|
|
|||
|
|
@ -33,8 +33,38 @@ export const LogsPage = ({ service, logs }: LogsPageProps) => (
|
|||
</a>
|
||||
</div>
|
||||
|
||||
<pre style="margin-top: 1rem;">
|
||||
<pre id="logs" style="margin-top: 1rem; max-height: 70vh; overflow-y: auto;">
|
||||
<code>{logs.trim()}</code>
|
||||
</pre>
|
||||
|
||||
<script dangerouslySetInnerHTML={{ __html: `
|
||||
(function() {
|
||||
const logsEl = document.getElementById('logs');
|
||||
const codeEl = logsEl.querySelector('code');
|
||||
let userScrolledUp = false;
|
||||
|
||||
logsEl.addEventListener('scroll', () => {
|
||||
const atBottom = logsEl.scrollTop + logsEl.clientHeight >= logsEl.scrollHeight - 50;
|
||||
userScrolledUp = !atBottom;
|
||||
});
|
||||
|
||||
// Scroll to bottom initially
|
||||
logsEl.scrollTop = logsEl.scrollHeight;
|
||||
|
||||
const service = new URLSearchParams(location.search).get('service') || 'phone-ap';
|
||||
const es = new EventSource('/logs/stream?service=' + encodeURIComponent(service));
|
||||
|
||||
es.onmessage = (e) => {
|
||||
codeEl.textContent += '\\n' + e.data;
|
||||
if (!userScrolledUp) {
|
||||
logsEl.scrollTop = logsEl.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
console.error('SSE connection lost, reconnecting...');
|
||||
};
|
||||
})();
|
||||
`}} />
|
||||
</Layout>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Hono } from "hono"
|
||||
import { streamSSE } from "hono/streaming"
|
||||
import { join } from "node:path"
|
||||
import { $ } from "bun"
|
||||
import { IndexPage } from "./components/IndexPage"
|
||||
|
|
@ -55,6 +56,36 @@ app.get("/logs", async (c) => {
|
|||
}
|
||||
})
|
||||
|
||||
// SSE endpoint for live log streaming
|
||||
app.get("/logs/stream", async (c) => {
|
||||
const service = c.req.query("service") || "phone"
|
||||
const validServices = ["phone-ap", "phone-web", "phone"]
|
||||
const selectedService = validServices.includes(service) ? service : "phone"
|
||||
|
||||
return streamSSE(c, async (stream) => {
|
||||
const proc = Bun.spawn(
|
||||
["journalctl", "-u", `${selectedService}.service`, "-f", "-n", "0", "--no-pager", "--no-hostname"],
|
||||
{ stdout: "pipe" }
|
||||
)
|
||||
|
||||
const reader = proc.stdout.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
const text = decoder.decode(value)
|
||||
for (const line of text.split("\n").filter(Boolean)) {
|
||||
await stream.writeSSE({ data: line })
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
proc.kill()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Handle WiFi configuration submission
|
||||
app.post("/save", async (c) => {
|
||||
const formData = await c.req.parseBody()
|
||||
|
|
|
|||
49
src/sip.ts
49
src/sip.ts
|
|
@ -5,10 +5,12 @@ import { processStdout, processStderr } from "./utils/stdio.ts"
|
|||
export class Baresip {
|
||||
baresipArgs: string[]
|
||||
process?: Bun.PipedSubprocess
|
||||
registered = false
|
||||
callEstablished = new Emitter<{ contact: string }>()
|
||||
callReceived = new Emitter<{ contact: string }>()
|
||||
hungUp = new Emitter()
|
||||
error = new Emitter<{ message: string }>()
|
||||
dialFailed = new Emitter<{ reason: string }>()
|
||||
error = new Emitter<{ message: string; statusCode?: string; reason?: string }>()
|
||||
registrationSuccess = new Emitter()
|
||||
|
||||
constructor(baresipArgs: string[]) {
|
||||
|
|
@ -39,8 +41,15 @@ export class Baresip {
|
|||
executeCommand("a")
|
||||
}
|
||||
|
||||
dial(phoneNumber: string) {
|
||||
executeCommand(`d${phoneNumber}`)
|
||||
async dial(phoneNumber: string) {
|
||||
if (!this.registered) {
|
||||
this.dialFailed.emit({ reason: "Not registered with SIP server" })
|
||||
return
|
||||
}
|
||||
const success = await executeCommand(`d${phoneNumber}`)
|
||||
if (!success) {
|
||||
this.dialFailed.emit({ reason: "Command timed out" })
|
||||
}
|
||||
}
|
||||
|
||||
hangUp() {
|
||||
|
|
@ -52,6 +61,8 @@ export class Baresip {
|
|||
this.callReceived.removeAllListeners()
|
||||
this.hungUp.removeAllListeners()
|
||||
this.registrationSuccess.removeAllListeners()
|
||||
this.dialFailed.removeAllListeners()
|
||||
this.error.removeAllListeners()
|
||||
}
|
||||
|
||||
kill() {
|
||||
|
|
@ -61,6 +72,15 @@ export class Baresip {
|
|||
this.process = undefined
|
||||
}
|
||||
|
||||
async restart() {
|
||||
this.registered = false
|
||||
if (this.process) {
|
||||
this.process.kill()
|
||||
this.process = undefined
|
||||
}
|
||||
await this.connect()
|
||||
}
|
||||
|
||||
#parseLine(line: string) {
|
||||
log.debug(`📞 Baresip: ${line}`)
|
||||
const callEstablishedMatch = line.match(/Call established: (.+)/)
|
||||
|
|
@ -88,27 +108,40 @@ export class Baresip {
|
|||
|
||||
const registrationSuccessMatch = line.match(/\[\d+ bindings?\]/)
|
||||
if (registrationSuccessMatch) {
|
||||
this.registered = true
|
||||
this.registrationSuccess.emit()
|
||||
}
|
||||
|
||||
const registrationFailedMatch = line.match(/reg: sip:\S+ 403 Forbidden/)
|
||||
const registrationFailedMatch = line.match(/reg: sip:\S+ .*?(\d{3}) (\w+)/)
|
||||
const socketInUseMatch = line.match(/tcp: sock_bind:/)
|
||||
if (registrationFailedMatch || socketInUseMatch) {
|
||||
log.error(`⁉️ NOT HANDLED: Registration failed with "${line}"`)
|
||||
if (registrationFailedMatch) {
|
||||
const [, statusCode, reason] = registrationFailedMatch
|
||||
log.error(`Registration failed: ${statusCode} ${reason}`)
|
||||
this.registered = false
|
||||
this.error.emit({ message: line, statusCode, reason })
|
||||
} else if (socketInUseMatch) {
|
||||
log.error(`Registration failed: socket in use`)
|
||||
this.registered = false
|
||||
this.error.emit({ message: line })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const executeCommand = async (command: string) => {
|
||||
const executeCommand = async (command: string): Promise<boolean> => {
|
||||
try {
|
||||
const url = new URL(`/?${command}`, "http://127.0.0.1:8000")
|
||||
const response = await Bun.fetch(url)
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 10000)
|
||||
|
||||
const response = await fetch(url, { signal: controller.signal })
|
||||
clearTimeout(timeout)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error executing command: ${response.statusText}`)
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
log.error("Failed to execute command:", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
55
src/vesta/README.md
Normal file
55
src/vesta/README.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# vesta
|
||||
|
||||
CLI tool for sending messages to a Vestaboard.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
Add your API key to `.env`:
|
||||
```
|
||||
VESTABOARD_API_KEY=your_key_here
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
bun src/cli.ts <plugin> [args...]
|
||||
```
|
||||
|
||||
Example:
|
||||
```bash
|
||||
bun src/cli.ts words hello world foo
|
||||
```
|
||||
|
||||
## Plugin Ideas
|
||||
|
||||
### Physical Interaction
|
||||
- **SMS gateway** - Text your board from anywhere, let guests text it at parties
|
||||
- **NFC tags** - Tap spots around your house to trigger different displays
|
||||
- **Pi buttons** - Physical "mood buttons" - hit one for motivation, one for jokes, one for chaos
|
||||
- **QR code guest book** - Visitors scan and leave a message
|
||||
|
||||
### Generative/Visual
|
||||
- **Game of Life** - Cellular automata with color tiles, evolving patterns
|
||||
- **Matrix rain** - Characters cascading down with color trails
|
||||
- **Waveform** - Audio input turns into color visualizations
|
||||
- **Sunrise simulator** - Color gradient that shifts throughout the day
|
||||
|
||||
### Data as Art
|
||||
- **GitHub-style heatmap** - Your daily activity as a color grid
|
||||
- **Air quality gradient** - Pulls AQI, renders as color mood
|
||||
- **Heart rate from Apple Watch** - Live biometric ambient display
|
||||
- **Network pulse** - Flickers when devices connect/disconnect
|
||||
|
||||
### Games via SMS
|
||||
- **Wordle** - Text guesses, board shows your progress
|
||||
- **Hangman** - Play with friends remotely
|
||||
- **Simon** - Color memory game with physical buttons
|
||||
|
||||
### Home Awareness
|
||||
- **Who's home** - Each family member gets a row, lights up based on presence
|
||||
- **Chore roulette** - Spin and assign tasks with flair
|
||||
- **Package tracker** - Delivery countdown with dramatic reveal
|
||||
107
src/vesta/cli.ts
Normal file
107
src/vesta/cli.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
#!/usr/bin/env bun
|
||||
import { sendGrid } from "./vestaboard"
|
||||
|
||||
const ROWS = 6
|
||||
const COLS = 22
|
||||
|
||||
const charToCode = (c: string): number => {
|
||||
if (c === " ") return 0
|
||||
if (c >= "A" && c <= "Z") return c.charCodeAt(0) - 64
|
||||
if (c >= "a" && c <= "z") return c.charCodeAt(0) - 96
|
||||
if (c >= "0" && c <= "9") return c.charCodeAt(0) - 48 + 27
|
||||
const special: Record<string, number> = {
|
||||
"!": 37,
|
||||
"@": 38,
|
||||
"#": 39,
|
||||
$: 40,
|
||||
"(": 41,
|
||||
")": 42,
|
||||
"-": 44,
|
||||
"+": 46,
|
||||
"&": 47,
|
||||
"=": 48,
|
||||
";": 49,
|
||||
":": 50,
|
||||
"'": 52,
|
||||
'"': 53,
|
||||
"%": 54,
|
||||
",": 55,
|
||||
".": 56,
|
||||
"/": 59,
|
||||
"?": 60,
|
||||
"°": 62,
|
||||
"🟥": 63,
|
||||
"🟧": 64,
|
||||
"🟨": 65,
|
||||
"🟩": 66,
|
||||
"🟦": 67,
|
||||
"🟪": 68,
|
||||
"⬜": 69,
|
||||
"⬛": 70,
|
||||
}
|
||||
return special[c] ?? 0
|
||||
}
|
||||
|
||||
const blankGrid = (): number[][] => Array.from({ length: ROWS }, () => Array(COLS).fill(0))
|
||||
|
||||
const textToGrid = (text: string): number[][] => {
|
||||
const grid = blankGrid()
|
||||
const lines = text.split("\n").slice(0, ROWS)
|
||||
|
||||
lines.forEach((line, row) => {
|
||||
const chars = [...line].slice(0, COLS)
|
||||
const startCol = Math.floor((COLS - chars.length) / 2)
|
||||
chars.forEach((char, i) => {
|
||||
grid[row]![startCol + i] = charToCode(char)
|
||||
})
|
||||
})
|
||||
|
||||
// Vertically center if fewer lines than rows
|
||||
if (lines.length < ROWS) {
|
||||
const offset = Math.floor((ROWS - lines.length) / 2)
|
||||
const centered = blankGrid()
|
||||
lines.forEach((_, i) => {
|
||||
centered[i + offset] = grid[i] as any
|
||||
})
|
||||
return centered
|
||||
}
|
||||
|
||||
return grid
|
||||
}
|
||||
|
||||
const usage = `
|
||||
Usage: bun src/vesta/cli.ts <message>
|
||||
|
||||
Examples:
|
||||
bun src/vesta/cli.ts "Hello World"
|
||||
bun src/vesta/cli.ts "Line 1" "Line 2" "Line 3"
|
||||
echo "Piped text" | bun src/vesta/cli.ts
|
||||
|
||||
Special characters: ! @ # $ ( ) - + & = ; : ' " % , . / ?
|
||||
Colors: 🟥 🟧 🟨 🟩 🟦 🟪 ⬜ ⬛
|
||||
`
|
||||
|
||||
const main = async () => {
|
||||
let text: string
|
||||
|
||||
if (process.argv.length > 2) {
|
||||
text = process.argv.slice(2).join("\n")
|
||||
} else if (!process.stdin.isTTY) {
|
||||
text = await Bun.stdin.text()
|
||||
} else {
|
||||
console.log(usage)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
text = text.trim()
|
||||
if (!text) {
|
||||
console.error("Error: Empty message")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`Sending: "${text.replace(/\n/g, " | ")}"`)
|
||||
const grid = textToGrid(text)
|
||||
await sendGrid(grid)
|
||||
}
|
||||
|
||||
main()
|
||||
258
src/vesta/draw.test.ts
Normal file
258
src/vesta/draw.test.ts
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import { test, expect } from "bun:test"
|
||||
import { draw, type DrawCommand } from "./draw"
|
||||
import { renderGridToPng } from "./render"
|
||||
import { mkdirSync, existsSync } from "fs"
|
||||
|
||||
const OUTPUT_DIR = `${import.meta.dir}/test-output`
|
||||
|
||||
if (!existsSync(OUTPUT_DIR)) {
|
||||
mkdirSync(OUTPUT_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
const saveTestPng = async (name: string, commands: DrawCommand[]) => {
|
||||
const grid = draw(commands)
|
||||
const png = await renderGridToPng(grid)
|
||||
await Bun.write(`${OUTPUT_DIR}/${name}.png`, png)
|
||||
return grid
|
||||
}
|
||||
|
||||
const ROWS = 6
|
||||
const COLS = 22
|
||||
|
||||
// Helper to safely access grid cells (we know grid is always 6x22)
|
||||
const at = (grid: number[][], r: number, c: number) => grid[r]![c]!
|
||||
const rowAt = (grid: number[][], r: number) => grid[r]!
|
||||
|
||||
test("fill - solid color", async () => {
|
||||
const grid = await saveTestPng("fill-solid-blue", [{ cmd: "fill", color: "blue" }])
|
||||
expect(grid.length).toBe(ROWS)
|
||||
expect(rowAt(grid, 0).length).toBe(COLS)
|
||||
expect(grid.every((r) => r.every((c) => c === 67))).toBe(true)
|
||||
})
|
||||
|
||||
test("rect - simple rectangle", async () => {
|
||||
const grid = await saveTestPng("rect-simple", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "rect", x: 2, y: 1, w: 5, h: 3, color: "red" },
|
||||
])
|
||||
expect(at(grid, 1, 2)).toBe(63) // red
|
||||
expect(at(grid, 2, 4)).toBe(63) // red
|
||||
expect(at(grid, 0, 0)).toBe(70) // black
|
||||
})
|
||||
|
||||
test("rect - full width row", async () => {
|
||||
const grid = await saveTestPng("rect-full-row", [
|
||||
{ cmd: "fill", color: "white" },
|
||||
{ cmd: "rect", x: 0, y: 0, w: 22, h: 1, color: "orange" },
|
||||
{ cmd: "rect", x: 0, y: 5, w: 22, h: 1, color: "orange" },
|
||||
])
|
||||
expect(rowAt(grid, 0).every((c) => c === 64)).toBe(true)
|
||||
expect(rowAt(grid, 5).every((c) => c === 64)).toBe(true)
|
||||
expect(rowAt(grid, 2).every((c) => c === 69)).toBe(true)
|
||||
})
|
||||
|
||||
test("text - centered (default)", async () => {
|
||||
const grid = await saveTestPng("text-centered", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "text", content: "HELLO", row: 2 },
|
||||
])
|
||||
// "HELLO" is 5 chars, centered in 22 = starts at position 8
|
||||
expect(at(grid, 2, 8)).toBe(8) // H
|
||||
expect(at(grid, 2, 9)).toBe(5) // E
|
||||
expect(at(grid, 2, 10)).toBe(12) // L
|
||||
expect(at(grid, 2, 11)).toBe(12) // L
|
||||
expect(at(grid, 2, 12)).toBe(15) // O
|
||||
})
|
||||
|
||||
test("text - left aligned", async () => {
|
||||
const grid = await saveTestPng("text-left", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "text", content: "HI", row: 0, align: "left" },
|
||||
])
|
||||
expect(at(grid, 0, 0)).toBe(8) // H
|
||||
expect(at(grid, 0, 1)).toBe(9) // I
|
||||
})
|
||||
|
||||
test("text - right aligned", async () => {
|
||||
const grid = await saveTestPng("text-right", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "text", content: "HI", row: 0, align: "right" },
|
||||
])
|
||||
expect(at(grid, 0, 20)).toBe(8) // H
|
||||
expect(at(grid, 0, 21)).toBe(9) // I
|
||||
})
|
||||
|
||||
test("text - with startCol/endCol bounds", async () => {
|
||||
const grid = await saveTestPng("text-bounded", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "text", content: "HI", row: 2, startCol: 5, endCol: 16, align: "center" },
|
||||
])
|
||||
// Width is 12 (5-16 inclusive), "HI" is 2 chars, centered = starts at col 10
|
||||
expect(at(grid, 2, 10)).toBe(8) // H
|
||||
expect(at(grid, 2, 11)).toBe(9) // I
|
||||
})
|
||||
|
||||
test("text - left aligned with bounds", async () => {
|
||||
const grid = await saveTestPng("text-bounded-left", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "text", content: "HI", row: 2, startCol: 4, endCol: 17, align: "left" },
|
||||
])
|
||||
expect(at(grid, 2, 4)).toBe(8) // H
|
||||
expect(at(grid, 2, 5)).toBe(9) // I
|
||||
})
|
||||
|
||||
test("text - right aligned with bounds", async () => {
|
||||
const grid = await saveTestPng("text-bounded-right", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "text", content: "HI", row: 2, startCol: 4, endCol: 17, align: "right" },
|
||||
])
|
||||
expect(at(grid, 2, 16)).toBe(8) // H
|
||||
expect(at(grid, 2, 17)).toBe(9) // I
|
||||
})
|
||||
|
||||
test("text_block - word wrap", async () => {
|
||||
const grid = await saveTestPng("text-block-wrap", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "text_block", content: "HAPPY BIRTHDAY BEATRICE", startRow: 1 },
|
||||
])
|
||||
expect(grid.length).toBe(ROWS)
|
||||
})
|
||||
|
||||
test("text_block - with column bounds", async () => {
|
||||
const grid = await saveTestPng("text-block-bounded", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "text_block", content: "THE QUICK BROWN FOX JUMPS", startRow: 0, startCol: 2, endCol: 19 },
|
||||
])
|
||||
// First and last 2 cols should remain black
|
||||
expect(at(grid, 0, 0)).toBe(70)
|
||||
expect(at(grid, 0, 1)).toBe(70)
|
||||
expect(at(grid, 0, 20)).toBe(70)
|
||||
expect(at(grid, 0, 21)).toBe(70)
|
||||
})
|
||||
|
||||
test("text_block - with row bounds", async () => {
|
||||
const grid = await saveTestPng("text-block-row-bounded", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "text_block", content: "A B C D E F G H I J K L M", startRow: 2, endRow: 4 },
|
||||
])
|
||||
// Should only use rows 2-4
|
||||
expect(at(grid, 0, 0)).toBe(70) // black, untouched
|
||||
expect(at(grid, 1, 0)).toBe(70) // black, untouched
|
||||
expect(at(grid, 5, 0)).toBe(70) // black, untouched
|
||||
})
|
||||
|
||||
test("text_block - overflow ellipsis", async () => {
|
||||
const grid = await saveTestPng("text-block-overflow", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "text_block", content: "THIS IS A VERY LONG MESSAGE THAT WILL NOT FIT", startRow: 2, endRow: 3, overflow: "ellipsis" },
|
||||
])
|
||||
expect(grid.length).toBe(ROWS)
|
||||
})
|
||||
|
||||
test("border - all sides", async () => {
|
||||
const grid = await saveTestPng("border-all", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "border", color: "blue" },
|
||||
])
|
||||
expect(rowAt(grid, 0).every((c) => c === 67)).toBe(true)
|
||||
expect(rowAt(grid, 5).every((c) => c === 67)).toBe(true)
|
||||
expect(at(grid, 2, 0)).toBe(67)
|
||||
expect(at(grid, 2, 21)).toBe(67)
|
||||
expect(at(grid, 2, 10)).toBe(70)
|
||||
})
|
||||
|
||||
test("border - top and bottom only", async () => {
|
||||
const grid = await saveTestPng("border-top-bottom", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "border", color: "orange", sides: ["top", "bottom"] },
|
||||
])
|
||||
expect(rowAt(grid, 0).every((c) => c === 64)).toBe(true)
|
||||
expect(rowAt(grid, 5).every((c) => c === 64)).toBe(true)
|
||||
expect(at(grid, 2, 0)).toBe(70)
|
||||
expect(at(grid, 2, 21)).toBe(70)
|
||||
})
|
||||
|
||||
test("gradient - horizontal", async () => {
|
||||
const grid = await saveTestPng("gradient-horizontal", [
|
||||
{ cmd: "gradient", direction: "horizontal", colors: ["purple", "blue", "green", "yellow", "orange", "red"] },
|
||||
])
|
||||
expect(at(grid, 0, 0)).toBe(68) // purple
|
||||
expect(at(grid, 0, 21)).toBe(63) // red
|
||||
})
|
||||
|
||||
test("gradient - vertical", async () => {
|
||||
const grid = await saveTestPng("gradient-vertical", [
|
||||
{ cmd: "gradient", direction: "vertical", colors: ["blue", "green", "yellow"] },
|
||||
])
|
||||
expect(at(grid, 0, 10)).toBe(67) // blue
|
||||
expect(at(grid, 5, 10)).toBe(65) // yellow
|
||||
})
|
||||
|
||||
test("circle - small", async () => {
|
||||
const grid = await saveTestPng("circle-small", [
|
||||
{ cmd: "fill", color: "white" },
|
||||
{ cmd: "circle", cx: 11, cy: 3, r: 2, color: "red" },
|
||||
])
|
||||
expect(at(grid, 3, 11)).toBe(63) // red center
|
||||
})
|
||||
|
||||
test("line - horizontal", async () => {
|
||||
const grid = await saveTestPng("line-horizontal", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "line", x1: 2, y1: 3, x2: 19, y2: 3, color: "yellow" },
|
||||
])
|
||||
expect(at(grid, 3, 2)).toBe(65)
|
||||
expect(at(grid, 3, 10)).toBe(65)
|
||||
expect(at(grid, 3, 19)).toBe(65)
|
||||
})
|
||||
|
||||
test("line - diagonal", async () => {
|
||||
const grid = await saveTestPng("line-diagonal", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "line", x1: 0, y1: 0, x2: 21, y2: 5, color: "green" },
|
||||
])
|
||||
expect(at(grid, 0, 0)).toBe(66)
|
||||
expect(at(grid, 5, 21)).toBe(66)
|
||||
})
|
||||
|
||||
test("combined - birthday message", async () => {
|
||||
await saveTestPng("combined-birthday", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "border", color: "orange", sides: ["top", "bottom"] },
|
||||
{ cmd: "text_block", content: "HAPPY BIRTHDAY BEATRICE", startRow: 2, endRow: 3 },
|
||||
])
|
||||
})
|
||||
|
||||
test("combined - quote with accent", async () => {
|
||||
await saveTestPng("combined-quote", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "text_block", content: "KINDLE A LIGHT OF MEANING IN THE DARKNESS", startRow: 0, endRow: 3, startCol: 2, endCol: 19 },
|
||||
{ cmd: "text", content: "-CARL JUNG", row: 5 },
|
||||
])
|
||||
})
|
||||
|
||||
test("combined - alert with border", async () => {
|
||||
await saveTestPng("combined-alert", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "border", color: "red" },
|
||||
{ cmd: "text", content: "ALERT", row: 2 },
|
||||
{ cmd: "text", content: "MEETING AT 6PM", row: 3 },
|
||||
])
|
||||
})
|
||||
|
||||
test("combined - asymmetric layout", async () => {
|
||||
await saveTestPng("combined-asymmetric", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "rect", x: 0, y: 0, w: 4, h: 6, color: "blue" },
|
||||
{ cmd: "text_block", content: "QUOTE GOES HERE ON THE RIGHT SIDE", startCol: 5, endCol: 21, startRow: 1, endRow: 4, align: "left" },
|
||||
])
|
||||
})
|
||||
|
||||
test("layered - shapes overlap correctly", async () => {
|
||||
await saveTestPng("layered-shapes", [
|
||||
{ cmd: "fill", color: "white" },
|
||||
{ cmd: "rect", x: 0, y: 0, w: 22, h: 3, color: "blue" },
|
||||
{ cmd: "rect", x: 5, y: 1, w: 12, h: 4, color: "yellow" },
|
||||
{ cmd: "circle", cx: 11, cy: 3, r: 2, color: "red" },
|
||||
])
|
||||
})
|
||||
366
src/vesta/draw.ts
Normal file
366
src/vesta/draw.ts
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
const ROWS = 6
|
||||
const COLS = 22
|
||||
|
||||
type Color = "red" | "orange" | "yellow" | "green" | "blue" | "purple" | "white" | "black"
|
||||
type Align = "left" | "center" | "right"
|
||||
type Side = "top" | "bottom" | "left" | "right"
|
||||
type Overflow = "ellipsis" | "truncate" | "squeeze" | "error"
|
||||
|
||||
const colorToCode: Record<Color, number> = {
|
||||
red: 63,
|
||||
orange: 64,
|
||||
yellow: 65,
|
||||
green: 66,
|
||||
blue: 67,
|
||||
purple: 68,
|
||||
white: 69,
|
||||
black: 70,
|
||||
}
|
||||
|
||||
const charToCode: Record<string, number> = {
|
||||
" ": 0,
|
||||
A: 1, B: 2, C: 3, D: 4, E: 5, F: 6, G: 7, H: 8, I: 9,
|
||||
J: 10, K: 11, L: 12, M: 13, N: 14, O: 15, P: 16, Q: 17,
|
||||
R: 18, S: 19, T: 20, U: 21, V: 22, W: 23, X: 24, Y: 25, Z: 26,
|
||||
"1": 27, "2": 28, "3": 29, "4": 30, "5": 31, "6": 32, "7": 33, "8": 34, "9": 35, "0": 36,
|
||||
"!": 37, "@": 38, "#": 39, "$": 40, "(": 41, ")": 42, "-": 44, "+": 46,
|
||||
"&": 47, "=": 48, ";": 49, ":": 50, "'": 52, '"': 53, "%": 54, ",": 55,
|
||||
".": 56, "/": 59, "?": 60, "°": 62,
|
||||
}
|
||||
|
||||
interface FillCmd {
|
||||
cmd: "fill"
|
||||
color: Color
|
||||
}
|
||||
|
||||
interface RectCmd {
|
||||
cmd: "rect"
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
color: Color
|
||||
}
|
||||
|
||||
interface CircleCmd {
|
||||
cmd: "circle"
|
||||
cx: number
|
||||
cy: number
|
||||
r: number
|
||||
color: Color
|
||||
}
|
||||
|
||||
interface LineCmd {
|
||||
cmd: "line"
|
||||
x1: number
|
||||
y1: number
|
||||
x2: number
|
||||
y2: number
|
||||
color: Color
|
||||
}
|
||||
|
||||
interface TextCmd {
|
||||
cmd: "text"
|
||||
content: string
|
||||
row: number
|
||||
align?: Align
|
||||
startCol?: number
|
||||
endCol?: number
|
||||
}
|
||||
|
||||
interface TextBlockCmd {
|
||||
cmd: "text_block"
|
||||
content: string
|
||||
startRow?: number
|
||||
endRow?: number
|
||||
startCol?: number
|
||||
endCol?: number
|
||||
align?: Align
|
||||
overflow?: Overflow
|
||||
}
|
||||
|
||||
interface BorderCmd {
|
||||
cmd: "border"
|
||||
color: Color
|
||||
sides?: Side[]
|
||||
}
|
||||
|
||||
interface GradientCmd {
|
||||
cmd: "gradient"
|
||||
direction: "horizontal" | "vertical"
|
||||
colors: Color[]
|
||||
}
|
||||
|
||||
export type DrawCommand =
|
||||
| FillCmd
|
||||
| RectCmd
|
||||
| CircleCmd
|
||||
| LineCmd
|
||||
| TextCmd
|
||||
| TextBlockCmd
|
||||
| BorderCmd
|
||||
| GradientCmd
|
||||
|
||||
const createGrid = (): number[][] => {
|
||||
return Array.from({ length: ROWS }, () => Array(COLS).fill(0))
|
||||
}
|
||||
|
||||
const setPixel = (grid: number[][], x: number, y: number, code: number) => {
|
||||
if (x >= 0 && x < COLS && y >= 0 && y < ROWS) {
|
||||
grid[y]![x] = code
|
||||
}
|
||||
}
|
||||
|
||||
const fill = (grid: number[][], color: Color) => {
|
||||
const code = colorToCode[color]
|
||||
for (let y = 0; y < ROWS; y++) {
|
||||
for (let x = 0; x < COLS; x++) {
|
||||
grid[y]![x] = code
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rect = (grid: number[][], x: number, y: number, w: number, h: number, color: Color) => {
|
||||
const code = colorToCode[color]
|
||||
for (let dy = 0; dy < h; dy++) {
|
||||
for (let dx = 0; dx < w; dx++) {
|
||||
setPixel(grid, x + dx, y + dy, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const circle = (grid: number[][], cx: number, cy: number, r: number, color: Color) => {
|
||||
const code = colorToCode[color]
|
||||
for (let y = 0; y < ROWS; y++) {
|
||||
for (let x = 0; x < COLS; x++) {
|
||||
const dx = x - cx
|
||||
const dy = y - cy
|
||||
if (dx * dx + dy * dy <= r * r) {
|
||||
grid[y]![x] = code
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const line = (grid: number[][], x1: number, y1: number, x2: number, y2: number, color: Color) => {
|
||||
const code = colorToCode[color]
|
||||
const dx = Math.abs(x2 - x1)
|
||||
const dy = Math.abs(y2 - y1)
|
||||
const sx = x1 < x2 ? 1 : -1
|
||||
const sy = y1 < y2 ? 1 : -1
|
||||
let err = dx - dy
|
||||
|
||||
let x = x1
|
||||
let y = y1
|
||||
|
||||
while (true) {
|
||||
setPixel(grid, x, y, code)
|
||||
if (x === x2 && y === y2) break
|
||||
const e2 = 2 * err
|
||||
if (e2 > -dy) {
|
||||
err -= dy
|
||||
x += sx
|
||||
}
|
||||
if (e2 < dx) {
|
||||
err += dx
|
||||
y += sy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const text = (
|
||||
grid: number[][],
|
||||
content: string,
|
||||
row: number,
|
||||
startCol = 0,
|
||||
endCol = COLS - 1,
|
||||
align: Align = "center"
|
||||
) => {
|
||||
const availableWidth = endCol - startCol + 1
|
||||
const textLen = Math.min(content.length, availableWidth)
|
||||
const truncatedContent = content.slice(0, textLen)
|
||||
|
||||
let startX: number
|
||||
if (align === "left") {
|
||||
startX = startCol
|
||||
} else if (align === "right") {
|
||||
startX = endCol - textLen + 1
|
||||
} else {
|
||||
// center
|
||||
startX = startCol + Math.floor((availableWidth - textLen) / 2)
|
||||
}
|
||||
|
||||
for (let i = 0; i < truncatedContent.length; i++) {
|
||||
const char = truncatedContent[i]!.toUpperCase()
|
||||
const code = charToCode[char] ?? 0
|
||||
setPixel(grid, startX + i, row, code)
|
||||
}
|
||||
}
|
||||
|
||||
const wrapText = (content: string, maxWidth: number): string[] => {
|
||||
const words = content.split(" ")
|
||||
const lines: string[] = []
|
||||
let currentLine = ""
|
||||
|
||||
for (const word of words) {
|
||||
if (word.length > maxWidth) {
|
||||
if (currentLine) {
|
||||
lines.push(currentLine.trim())
|
||||
currentLine = ""
|
||||
}
|
||||
let remaining = word
|
||||
while (remaining.length > maxWidth) {
|
||||
lines.push(remaining.slice(0, maxWidth - 1) + "-")
|
||||
remaining = remaining.slice(maxWidth - 1)
|
||||
}
|
||||
currentLine = remaining
|
||||
} else if ((currentLine + " " + word).trim().length <= maxWidth) {
|
||||
currentLine = (currentLine + " " + word).trim()
|
||||
} else {
|
||||
if (currentLine) {
|
||||
lines.push(currentLine)
|
||||
}
|
||||
currentLine = word
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
lines.push(currentLine)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
const textBlock = (
|
||||
grid: number[][],
|
||||
content: string,
|
||||
startRow = 0,
|
||||
endRow = ROWS - 1,
|
||||
startCol = 0,
|
||||
endCol = COLS - 1,
|
||||
align: Align = "center",
|
||||
overflow: Overflow = "ellipsis"
|
||||
) => {
|
||||
const availableWidth = endCol - startCol + 1
|
||||
const availableRows = endRow - startRow + 1
|
||||
let lines = wrapText(content, availableWidth)
|
||||
|
||||
if (lines.length > availableRows) {
|
||||
if (overflow === "ellipsis") {
|
||||
lines = lines.slice(0, availableRows)
|
||||
const lastLine = lines[lines.length - 1]!
|
||||
if (lastLine.length > availableWidth - 3) {
|
||||
lines[lines.length - 1] = lastLine.slice(0, availableWidth - 3) + "..."
|
||||
} else {
|
||||
lines[lines.length - 1] = lastLine + "..."
|
||||
}
|
||||
} else if (overflow === "truncate") {
|
||||
lines = lines.slice(0, availableRows)
|
||||
} else if (overflow === "squeeze") {
|
||||
// Try expanding bounds by 1 on each side
|
||||
const newStartCol = Math.max(0, startCol - 1)
|
||||
const newEndCol = Math.min(COLS - 1, endCol + 1)
|
||||
if (newStartCol < startCol || newEndCol > endCol) {
|
||||
return textBlock(grid, content, startRow, endRow, newStartCol, newEndCol, align, overflow)
|
||||
}
|
||||
lines = lines.slice(0, availableRows)
|
||||
} else if (overflow === "error") {
|
||||
throw new Error(`Text overflow: ${lines.length} lines needed, only ${availableRows} available`)
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const row = startRow + i
|
||||
if (row <= endRow) {
|
||||
text(grid, lines[i]!, row, startCol, endCol, align)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const border = (grid: number[][], color: Color, sides?: Side[]) => {
|
||||
const code = colorToCode[color]
|
||||
const allSides = sides ?? ["top", "bottom", "left", "right"]
|
||||
|
||||
if (allSides.includes("top")) {
|
||||
for (let x = 0; x < COLS; x++) {
|
||||
grid[0]![x] = code
|
||||
}
|
||||
}
|
||||
|
||||
if (allSides.includes("bottom")) {
|
||||
for (let x = 0; x < COLS; x++) {
|
||||
grid[ROWS - 1]![x] = code
|
||||
}
|
||||
}
|
||||
|
||||
if (allSides.includes("left")) {
|
||||
for (let y = 0; y < ROWS; y++) {
|
||||
grid[y]![0] = code
|
||||
}
|
||||
}
|
||||
|
||||
if (allSides.includes("right")) {
|
||||
for (let y = 0; y < ROWS; y++) {
|
||||
grid[y]![COLS - 1] = code
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const gradient = (grid: number[][], direction: "horizontal" | "vertical", colors: Color[]) => {
|
||||
const codes = colors.map(c => colorToCode[c])
|
||||
|
||||
if (direction === "horizontal") {
|
||||
for (let x = 0; x < COLS; x++) {
|
||||
const t = x / (COLS - 1)
|
||||
const idx = Math.min(Math.floor(t * codes.length), codes.length - 1)
|
||||
const code = codes[idx]!
|
||||
for (let y = 0; y < ROWS; y++) {
|
||||
grid[y]![x] = code
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let y = 0; y < ROWS; y++) {
|
||||
const t = y / (ROWS - 1)
|
||||
const idx = Math.min(Math.floor(t * codes.length), codes.length - 1)
|
||||
const code = codes[idx]!
|
||||
for (let x = 0; x < COLS; x++) {
|
||||
grid[y]![x] = code
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const draw = (commands: DrawCommand[]): number[][] => {
|
||||
const grid = createGrid()
|
||||
|
||||
for (const cmd of commands) {
|
||||
switch (cmd.cmd) {
|
||||
case "fill":
|
||||
fill(grid, cmd.color)
|
||||
break
|
||||
case "rect":
|
||||
rect(grid, cmd.x, cmd.y, cmd.w, cmd.h, cmd.color)
|
||||
break
|
||||
case "circle":
|
||||
circle(grid, cmd.cx, cmd.cy, cmd.r, cmd.color)
|
||||
break
|
||||
case "line":
|
||||
line(grid, cmd.x1, cmd.y1, cmd.x2, cmd.y2, cmd.color)
|
||||
break
|
||||
case "text":
|
||||
text(grid, cmd.content, cmd.row, cmd.startCol, cmd.endCol, cmd.align)
|
||||
break
|
||||
case "text_block":
|
||||
textBlock(grid, cmd.content, cmd.startRow, cmd.endRow, cmd.startCol, cmd.endCol, cmd.align, cmd.overflow)
|
||||
break
|
||||
case "border":
|
||||
border(grid, cmd.color, cmd.sides)
|
||||
break
|
||||
case "gradient":
|
||||
gradient(grid, cmd.direction, cmd.colors)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return grid
|
||||
}
|
||||
991
src/vesta/examples.json
Normal file
991
src/vesta/examples.json
Normal file
|
|
@ -0,0 +1,991 @@
|
|||
{
|
||||
"testPrompts": [
|
||||
"Happy birthday Beatrice! She is turning 11.",
|
||||
"Put up a morning motivational quote",
|
||||
"Game night at 7pm tonight!",
|
||||
"Make some pretty wintery art",
|
||||
"Soccer practice is cancelled today",
|
||||
"Pizza night! Dinner at 6",
|
||||
"Welcome home grandma!",
|
||||
"5 days until vacation!",
|
||||
"Don't forget your umbrella, it's going to rain",
|
||||
"Good luck on your test today Emma!",
|
||||
"Happy anniversary mom and dad!",
|
||||
"Please remember to feed the dog",
|
||||
"Merry Christmas!",
|
||||
"Big game today - go wildcats!",
|
||||
"The Johnsons are coming for dinner at 7",
|
||||
"Congrats on the promotion!",
|
||||
"Lights out at 9pm tonight",
|
||||
"Taco Tuesday!",
|
||||
"I love you, have a great day",
|
||||
"Movie night - pick your favorite!"
|
||||
],
|
||||
"goldExamples": [
|
||||
{
|
||||
"input": "Give me a matt cooke motivational quote",
|
||||
"output": [
|
||||
{
|
||||
"cmd": "fill",
|
||||
"color": "black"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "THERE'S A FUTURE",
|
||||
"row": 0,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "VERSION OF YOU TELLING",
|
||||
"row": 1,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "THE STORY OF HOW IT",
|
||||
"row": 2,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "ALL CAME TOGETHER.",
|
||||
"row": 3,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "-MATT COOKE",
|
||||
"row": 4,
|
||||
"align": "left"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"input": "Create a tour countdown that shows 15 minutes left",
|
||||
"output": [
|
||||
{
|
||||
"cmd": "fill",
|
||||
"color": "black"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "red"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 1,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "orange"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 2,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 3,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "green"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 4,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 5,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "purple"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 6,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "red"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 7,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "orange"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 8,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 9,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "green"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 10,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 11,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "purple"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 12,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "red"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 13,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "orange"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 14,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 15,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "green"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 16,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 17,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "purple"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 18,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "red"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 19,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "orange"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 20,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 21,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "green"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 0,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "orange"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 1,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "red"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 2,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "purple"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 3,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 4,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "green"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 5,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 6,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "orange"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 7,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "red"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 8,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "purple"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 9,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 10,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "green"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 11,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 12,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "orange"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 13,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "red"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 14,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "purple"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 15,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 16,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "green"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 17,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 18,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "orange"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 19,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "red"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 20,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "purple"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 21,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "WELCOME! THE NEXT",
|
||||
"row": 1,
|
||||
"startCol": 2,
|
||||
"endCol": 19,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "TOUR STARTS IN...",
|
||||
"row": 2,
|
||||
"startCol": 2,
|
||||
"endCol": 19,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "15 MINUTES!",
|
||||
"row": 4
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"input": "Say mazel tov to zev!",
|
||||
"output": [
|
||||
{
|
||||
"cmd": "fill",
|
||||
"color": "black"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 22,
|
||||
"h": 1,
|
||||
"color": "white"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 0,
|
||||
"y": 1,
|
||||
"w": 22,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 0,
|
||||
"y": 4,
|
||||
"w": 22,
|
||||
"h": 1,
|
||||
"color": "white"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 0,
|
||||
"y": 5,
|
||||
"w": 22,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 0,
|
||||
"y": 2,
|
||||
"w": 3,
|
||||
"h": 1,
|
||||
"color": "white"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 19,
|
||||
"y": 2,
|
||||
"w": 3,
|
||||
"h": 1,
|
||||
"color": "white"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 0,
|
||||
"y": 3,
|
||||
"w": 3,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 19,
|
||||
"y": 3,
|
||||
"w": 3,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "MAZEL TOV!",
|
||||
"row": 2
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "ZEV",
|
||||
"row": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"input": "Put up a bedtime reminder",
|
||||
"output": [
|
||||
{
|
||||
"cmd": "fill",
|
||||
"color": "black"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "BEDTIME",
|
||||
"row": 0,
|
||||
"startCol": 1,
|
||||
"endCol": 8,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 15,
|
||||
"y": 0,
|
||||
"w": 4,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 17,
|
||||
"y": 1,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 16,
|
||||
"y": 2,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 15,
|
||||
"y": 3,
|
||||
"w": 4,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 9,
|
||||
"y": 1,
|
||||
"w": 4,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 11,
|
||||
"y": 2,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 10,
|
||||
"y": 3,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 9,
|
||||
"y": 4,
|
||||
"w": 4,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 3,
|
||||
"y": 2,
|
||||
"w": 4,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 5,
|
||||
"y": 3,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 4,
|
||||
"y": 4,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 3,
|
||||
"y": 5,
|
||||
"w": 4,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"input": "Show a train schedule for the polar express",
|
||||
"output": [
|
||||
{
|
||||
"cmd": "fill",
|
||||
"color": "black"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 2,
|
||||
"h": 1,
|
||||
"color": "red"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 20,
|
||||
"y": 0,
|
||||
"w": 2,
|
||||
"h": 1,
|
||||
"color": "red"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 0,
|
||||
"y": 1,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "red"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 21,
|
||||
"y": 1,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "red"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "POLAR EXPRESS",
|
||||
"row": 0
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "12:00 AM",
|
||||
"row": 1
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "NORTH",
|
||||
"row": 2,
|
||||
"startCol": 2,
|
||||
"endCol": 10,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "SOUTH",
|
||||
"row": 2,
|
||||
"startCol": 13,
|
||||
"endCol": 21,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 0,
|
||||
"y": 3,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 0,
|
||||
"y": 4,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 0,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 11,
|
||||
"y": 3,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 11,
|
||||
"y": 4,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 11,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "03 MINS",
|
||||
"row": 3,
|
||||
"startCol": 2,
|
||||
"endCol": 10,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "07 MINS",
|
||||
"row": 4,
|
||||
"startCol": 2,
|
||||
"endCol": 10,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "10 MINS",
|
||||
"row": 5,
|
||||
"startCol": 2,
|
||||
"endCol": 10,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "02 MINS",
|
||||
"row": 3,
|
||||
"startCol": 13,
|
||||
"endCol": 21,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "06 MINS",
|
||||
"row": 4,
|
||||
"startCol": 13,
|
||||
"endCol": 21,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "09 MINS",
|
||||
"row": 5,
|
||||
"startCol": 13,
|
||||
"endCol": 21,
|
||||
"align": "left"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"input": "Create a stretch reminder for me",
|
||||
"output": [
|
||||
{
|
||||
"cmd": "fill",
|
||||
"color": "black"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "STAND UP",
|
||||
"row": 2,
|
||||
"startCol": 1,
|
||||
"endCol": 10,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "& STRETCH",
|
||||
"row": 3,
|
||||
"startCol": 1,
|
||||
"endCol": 10,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 16,
|
||||
"y": 1,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 13,
|
||||
"y": 2,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 14,
|
||||
"y": 2,
|
||||
"w": 5,
|
||||
"h": 1,
|
||||
"color": "red"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 19,
|
||||
"y": 2,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 15,
|
||||
"y": 3,
|
||||
"w": 3,
|
||||
"h": 1,
|
||||
"color": "red"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 15,
|
||||
"y": 4,
|
||||
"w": 3,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 15,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 17,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"input": "birthday message for Sarah with confetti",
|
||||
"output": [
|
||||
{ "cmd": "fill", "color": "black" },
|
||||
{ "cmd": "rect", "x": 0, "y": 0, "w": 1, "h": 1, "color": "red" },
|
||||
{ "cmd": "rect", "x": 2, "y": 0, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 4, "y": 0, "w": 1, "h": 1, "color": "blue" },
|
||||
{ "cmd": "rect", "x": 6, "y": 0, "w": 1, "h": 1, "color": "purple" },
|
||||
{ "cmd": "rect", "x": 15, "y": 0, "w": 1, "h": 1, "color": "purple" },
|
||||
{ "cmd": "rect", "x": 17, "y": 0, "w": 1, "h": 1, "color": "blue" },
|
||||
{ "cmd": "rect", "x": 19, "y": 0, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 21, "y": 0, "w": 1, "h": 1, "color": "red" },
|
||||
{ "cmd": "rect", "x": 1, "y": 1, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 5, "y": 1, "w": 1, "h": 1, "color": "green" },
|
||||
{ "cmd": "rect", "x": 16, "y": 1, "w": 1, "h": 1, "color": "green" },
|
||||
{ "cmd": "rect", "x": 20, "y": 1, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "text", "content": "HAPPY BIRTHDAY", "row": 2 },
|
||||
{ "cmd": "text", "content": "SARAH!", "row": 3 },
|
||||
{ "cmd": "rect", "x": 1, "y": 4, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 5, "y": 4, "w": 1, "h": 1, "color": "green" },
|
||||
{ "cmd": "rect", "x": 16, "y": 4, "w": 1, "h": 1, "color": "green" },
|
||||
{ "cmd": "rect", "x": 20, "y": 4, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 0, "y": 5, "w": 1, "h": 1, "color": "red" },
|
||||
{ "cmd": "rect", "x": 2, "y": 5, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 4, "y": 5, "w": 1, "h": 1, "color": "blue" },
|
||||
{ "cmd": "rect", "x": 6, "y": 5, "w": 1, "h": 1, "color": "purple" },
|
||||
{ "cmd": "rect", "x": 15, "y": 5, "w": 1, "h": 1, "color": "purple" },
|
||||
{ "cmd": "rect", "x": 17, "y": 5, "w": 1, "h": 1, "color": "blue" },
|
||||
{ "cmd": "rect", "x": 19, "y": 5, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 21, "y": 5, "w": 1, "h": 1, "color": "red" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"input": "Baby Emma was born on January 15th 2024 at 3:42pm, 7 pounds 4 ounces, 20 inches",
|
||||
"output": [
|
||||
{ "cmd": "fill", "color": "black" },
|
||||
{ "cmd": "rect", "x": 0, "y": 1, "w": 22, "h": 1, "color": "red" },
|
||||
{ "cmd": "text", "content": "BABY EMMA", "row": 0 },
|
||||
{ "cmd": "text", "content": "DATE: JAN 15 2024", "row": 2, "startCol": 1, "endCol": 21, "align": "left" },
|
||||
{ "cmd": "text", "content": "TIME: 3:42 PM", "row": 3, "startCol": 1, "endCol": 21, "align": "left" },
|
||||
{ "cmd": "text", "content": "WEIGHT: 7 LBS 4 OZ", "row": 4, "startCol": 1, "endCol": 21, "align": "left" },
|
||||
{ "cmd": "text", "content": "LENGTH: 20 IN", "row": 5, "startCol": 1, "endCol": 21, "align": "left" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"input": "Congrats on the promotion!",
|
||||
"output": [
|
||||
{ "cmd": "fill", "color": "black" },
|
||||
{ "cmd": "rect", "x": 0, "y": 0, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 1, "y": 0, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 2, "y": 0, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 3, "y": 0, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 4, "y": 0, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 5, "y": 0, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 6, "y": 0, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 7, "y": 0, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 8, "y": 0, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 9, "y": 0, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 10, "y": 0, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 11, "y": 0, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 12, "y": 0, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 13, "y": 0, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 14, "y": 0, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 15, "y": 0, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 16, "y": 0, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 17, "y": 0, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 18, "y": 0, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 19, "y": 0, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 20, "y": 0, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 21, "y": 0, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 0, "y": 5, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 1, "y": 5, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 2, "y": 5, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 3, "y": 5, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 4, "y": 5, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 5, "y": 5, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 6, "y": 5, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 7, "y": 5, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 8, "y": 5, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 9, "y": 5, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 10, "y": 5, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 11, "y": 5, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 12, "y": 5, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 13, "y": 5, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 14, "y": 5, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 15, "y": 5, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 16, "y": 5, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 17, "y": 5, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 18, "y": 5, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 19, "y": 5, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 20, "y": 5, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 21, "y": 5, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "text", "content": "CONGRATS ON THE", "row": 2 },
|
||||
{ "cmd": "text", "content": "PROMOTION!", "row": 3 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
55
src/vesta/generate.ts
Normal file
55
src/vesta/generate.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { GoogleGenerativeAI } from "@google/generative-ai"
|
||||
import { draw, type DrawCommand } from "./draw"
|
||||
import { sendGrid } from "./vestaboard"
|
||||
|
||||
const buildSystemPrompt = async (): Promise<string> => {
|
||||
const promptMd = await Bun.file(`${import.meta.dir}/prompt.md`).text()
|
||||
const examplesJson = await Bun.file(`${import.meta.dir}/examples.json`).json()
|
||||
|
||||
const examplesText = examplesJson.goldExamples
|
||||
.map((ex: { input: string; output: unknown }) => `Input: "${ex.input}"\nOutput: ${JSON.stringify(ex.output)}`)
|
||||
.join("\n\n")
|
||||
|
||||
return `${promptMd}
|
||||
|
||||
## Examples
|
||||
|
||||
${examplesText}`
|
||||
}
|
||||
|
||||
export const generateVestaboard = async (query: string): Promise<string> => {
|
||||
const apiKey = process.env.GEMINI_API_KEY
|
||||
if (!apiKey) {
|
||||
throw new Error("GEMINI_API_KEY not set in environment")
|
||||
}
|
||||
|
||||
const genAI = new GoogleGenerativeAI(apiKey)
|
||||
const model = genAI.getGenerativeModel({
|
||||
model: "gemini-2.5-flash",
|
||||
generationConfig: {
|
||||
temperature: 0.7,
|
||||
responseMimeType: "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
const systemPrompt = await buildSystemPrompt()
|
||||
|
||||
const result = await model.generateContent(`${systemPrompt}\n\n## User Request\n\n${query}`)
|
||||
const text = result.response.text()
|
||||
|
||||
let commands: DrawCommand[]
|
||||
try {
|
||||
commands = JSON.parse(text)
|
||||
} catch {
|
||||
throw new Error(`Failed to parse Gemini response as JSON: ${text}`)
|
||||
}
|
||||
|
||||
if (!Array.isArray(commands)) {
|
||||
throw new Error(`Expected array of commands, got: ${typeof commands}`)
|
||||
}
|
||||
|
||||
const grid = draw(commands)
|
||||
await sendGrid(grid)
|
||||
|
||||
return "The vestaboard design has been generated and sent successfully."
|
||||
}
|
||||
27
src/vesta/prompt.md
Normal file
27
src/vesta/prompt.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Vestaboard Layout Generator
|
||||
|
||||
Output a JSON array of draw commands for a 6-row × 22-column display.
|
||||
|
||||
## Rules
|
||||
1. First command MUST be `fill`
|
||||
2. Text has black cell backgrounds - use black `rect` behind text on colored backgrounds
|
||||
3. Colors: red, orange, yellow, green, blue, purple, white, black
|
||||
4. Coordinates: rows 0-5, columns 0-21
|
||||
|
||||
## Commands
|
||||
|
||||
```
|
||||
fill { "cmd": "fill", "color": "black" }
|
||||
rect { "cmd": "rect", "x": 0, "y": 0, "w": 22, "h": 1, "color": "orange" }
|
||||
text { "cmd": "text", "content": "HELLO", "row": 2, "color": "white" }
|
||||
text_block { "cmd": "text_block", "content": "LONG MESSAGE", "startRow": 1, "endRow": 4, "color": "white" }
|
||||
border { "cmd": "border", "color": "blue", "sides": ["top", "bottom"] }
|
||||
gradient { "cmd": "gradient", "direction": "horizontal", "colors": ["blue", "green", "yellow"] }
|
||||
circle { "cmd": "circle", "cx": 11, "cy": 3, "r": 2, "color": "red" }
|
||||
line { "cmd": "line", "x1": 0, "y1": 0, "x2": 21, "y2": 5, "color": "green" }
|
||||
```
|
||||
|
||||
text/text_block options: `align` (left/center/right), `startCol`, `endCol`
|
||||
|
||||
## Output
|
||||
JSON array only. No markdown, no explanation.
|
||||
151
src/vesta/render.ts
Normal file
151
src/vesta/render.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import sharp from "sharp"
|
||||
|
||||
// Character to vestaboard code mapping
|
||||
const charToCode: Record<string, number> = {
|
||||
" ": 0,
|
||||
A: 1, B: 2, C: 3, D: 4, E: 5, F: 6, G: 7, H: 8, I: 9,
|
||||
J: 10, K: 11, L: 12, M: 13, N: 14, O: 15, P: 16, Q: 17,
|
||||
R: 18, S: 19, T: 20, U: 21, V: 22, W: 23, X: 24, Y: 25, Z: 26,
|
||||
"1": 27, "2": 28, "3": 29, "4": 30, "5": 31, "6": 32, "7": 33, "8": 34, "9": 35, "0": 36,
|
||||
"!": 37, "@": 38, "#": 39, "$": 40, "(": 41, ")": 42, "-": 44, "+": 46,
|
||||
"&": 47, "=": 48, ";": 49, ":": 50, "'": 52, '"': 53, "%": 54, ",": 55,
|
||||
".": 56, "/": 59, "?": 60, "°": 62,
|
||||
"🟥": 63, "🟧": 64, "🟨": 65, "🟩": 66, "🟦": 67, "🟪": 68, "⬜": 69, "⬛": 70,
|
||||
}
|
||||
|
||||
// Code to display info for rendering
|
||||
const codeToDisplay: Record<number, { bg: string; fg: string; char?: string }> = {
|
||||
0: { bg: "#1a1a1a", fg: "#ffffff" }, // space (empty black tile)
|
||||
63: { bg: "#e63946", fg: "#ffffff" }, // red
|
||||
64: { bg: "#f4a261", fg: "#1a1a1a" }, // orange
|
||||
65: { bg: "#e9c46a", fg: "#1a1a1a" }, // yellow
|
||||
66: { bg: "#2a9d8f", fg: "#ffffff" }, // green
|
||||
67: { bg: "#0077b6", fg: "#ffffff" }, // blue
|
||||
68: { bg: "#9b5de5", fg: "#ffffff" }, // purple
|
||||
69: { bg: "#ffffff", fg: "#1a1a1a" }, // white
|
||||
70: { bg: "#1a1a1a", fg: "#ffffff" }, // black
|
||||
}
|
||||
|
||||
// Code to character (for letters/numbers/symbols)
|
||||
const codeToChar: Record<number, string> = {}
|
||||
for (const [char, code] of Object.entries(charToCode)) {
|
||||
if (code >= 1 && code <= 62) {
|
||||
codeToChar[code] = char
|
||||
}
|
||||
}
|
||||
|
||||
const TILE_SIZE = 24
|
||||
const GAP = 2
|
||||
const COLS = 22
|
||||
const ROWS = 6
|
||||
|
||||
// Parse emoji grid string to number array
|
||||
export const parseEmojiGrid = (grid: string): number[][] => {
|
||||
const lines = grid.trim().split("\n")
|
||||
const result: number[][] = []
|
||||
|
||||
for (const line of lines) {
|
||||
const row: number[] = []
|
||||
const chars = [...line] // Handle multi-byte emoji correctly
|
||||
|
||||
for (const char of chars) {
|
||||
const code = charToCode[char.toUpperCase()]
|
||||
if (code !== undefined) {
|
||||
row.push(code)
|
||||
}
|
||||
}
|
||||
|
||||
// Pad or trim to 22 columns
|
||||
while (row.length < COLS) row.push(0)
|
||||
if (row.length > COLS) row.length = COLS
|
||||
|
||||
result.push(row)
|
||||
}
|
||||
|
||||
// Pad or trim to 6 rows
|
||||
while (result.length < ROWS) result.push(Array(COLS).fill(0))
|
||||
if (result.length > ROWS) result.length = ROWS
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Convert number grid to emoji string
|
||||
export const gridToEmoji = (grid: number[][]): string => {
|
||||
const emojiMap: Record<number, string> = {
|
||||
0: " ",
|
||||
63: "🟥", 64: "🟧", 65: "🟨", 66: "🟩", 67: "🟦", 68: "🟪", 69: "⬜", 70: "⬛",
|
||||
}
|
||||
|
||||
return grid
|
||||
.map((row) =>
|
||||
row
|
||||
.map((code) => {
|
||||
if (emojiMap[code] !== undefined) return emojiMap[code]
|
||||
if (codeToChar[code]) return codeToChar[code]
|
||||
return " "
|
||||
})
|
||||
.join("")
|
||||
)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
// Render grid to SVG string
|
||||
const gridToSvg = (grid: number[][]): string => {
|
||||
const width = COLS * TILE_SIZE + (COLS - 1) * GAP
|
||||
const height = ROWS * TILE_SIZE + (ROWS - 1) * GAP
|
||||
|
||||
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`
|
||||
svg += `<rect width="${width}" height="${height}" fill="#0d0d0d"/>` // background
|
||||
|
||||
for (let row = 0; row < ROWS; row++) {
|
||||
for (let col = 0; col < COLS; col++) {
|
||||
const code = grid[row]![col]!
|
||||
const x = col * (TILE_SIZE + GAP)
|
||||
const y = row * (TILE_SIZE + GAP)
|
||||
|
||||
// Determine tile appearance
|
||||
let bg = "#1a1a1a"
|
||||
let fg = "#ffffff"
|
||||
let char: string | undefined
|
||||
|
||||
const display = codeToDisplay[code]
|
||||
if (display) {
|
||||
bg = display.bg
|
||||
fg = display.fg
|
||||
} else if (codeToChar[code]) {
|
||||
char = codeToChar[code]
|
||||
bg = "#1a1a1a"
|
||||
fg = "#ffffff"
|
||||
}
|
||||
|
||||
// Draw tile
|
||||
svg += `<rect x="${x}" y="${y}" width="${TILE_SIZE}" height="${TILE_SIZE}" rx="2" fill="${bg}"/>`
|
||||
|
||||
// Draw character if present
|
||||
if (char) {
|
||||
const fontSize = 14
|
||||
const textX = x + TILE_SIZE / 2
|
||||
const textY = y + TILE_SIZE / 2 + fontSize * 0.35
|
||||
// Escape XML special characters
|
||||
const escaped = char.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
svg += `<text x="${textX}" y="${textY}" font-family="Arial, sans-serif" font-size="${fontSize}" font-weight="bold" fill="${fg}" text-anchor="middle">${escaped}</text>`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
svg += "</svg>"
|
||||
return svg
|
||||
}
|
||||
|
||||
// Render emoji grid string to PNG buffer
|
||||
export const renderToPng = async (emojiGrid: string): Promise<Buffer> => {
|
||||
const grid = parseEmojiGrid(emojiGrid)
|
||||
const svg = gridToSvg(grid)
|
||||
return sharp(Buffer.from(svg)).png().toBuffer()
|
||||
}
|
||||
|
||||
// Render number grid to PNG buffer
|
||||
export const renderGridToPng = async (grid: number[][]): Promise<Buffer> => {
|
||||
const svg = gridToSvg(grid)
|
||||
return sharp(Buffer.from(svg)).png().toBuffer()
|
||||
}
|
||||
26
src/vesta/vestaboard.ts
Normal file
26
src/vesta/vestaboard.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
const API_URL = "https://rw.vestaboard.com/"
|
||||
|
||||
export const sendGrid = async (grid: number[][]): Promise<void> => {
|
||||
const apiKey = process.env.VESTABOARD_API_KEY
|
||||
if (!apiKey) {
|
||||
throw new Error("VESTABOARD_API_KEY not set in environment")
|
||||
}
|
||||
|
||||
console.log(`🌭 sending`)
|
||||
const response = await fetch(API_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Vestaboard-Read-Write-Key": apiKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(grid),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
console.log(`🌭 Error ${text}`)
|
||||
throw new Error(`Vestaboard API error (${response.status}): ${text}`)
|
||||
} else {
|
||||
console.log(`🌭 sent successfully`, await response.text())
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user