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
|
# Finder (MacOS) folder config
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
.claude
|
.claude.worktrees/
|
||||||
|
.worktrees/
|
||||||
|
|
||||||
|
docs/learning/
|
||||||
|
docs/plans/
|
||||||
28
bun.lock
28
bun.lock
|
|
@ -6,14 +6,12 @@
|
||||||
"name": "tmp",
|
"name": "tmp",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"robot3": "./packages/robot3",
|
"robot3": "./packages/robot3",
|
||||||
"@google/generative-ai": "^0.24.1",
|
"hono": "^4.10.4",
|
||||||
"hono": "^4.11.3",
|
"openai": "^6.9.0",
|
||||||
"openai": "^6.15.0",
|
|
||||||
"zod": "^4.3.4",
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "1.3.5",
|
"@types/bun": "latest",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.6.2",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
|
|
@ -21,8 +19,6 @@
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@google/generative-ai": ["@google/generative-ai@0.24.1", "", {}, "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q=="],
|
|
||||||
|
|
||||||
"@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/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/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||||
|
|
@ -33,25 +29,29 @@
|
||||||
|
|
||||||
"@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=="],
|
"@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.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
|
"@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/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/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=="],
|
"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=="],
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
|
"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=="],
|
"commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||||
|
|
||||||
"hono": ["hono@4.11.3", "", {}, "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w=="],
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
"openai": ["openai@6.15.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-F1Lvs5BoVvmZtzkUEVyh8mDQPPFolq4F+xdsx/DO8Hee8YF3IGAlZqUIsF+DVGhqf4aU0a3bTghsxB6OIsRy1g=="],
|
"hono": ["hono@4.10.6", "", {}, "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g=="],
|
||||||
|
|
||||||
"prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="],
|
"openai": ["openai@6.9.1", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-vQ5Rlt0ZgB3/BNmTa7bIijYFhz3YBceAA3Z4JuoMSBftBF9YqFHIEhZakSs+O/Ad7EaoEimZvHxD5ylRjN11Lg=="],
|
||||||
|
|
||||||
|
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
|
||||||
|
|
||||||
"robot3": ["robot3@file:packages/robot3", { "devDependencies": { "rollup": "^1.21.4", "terser": "^5.16.1" } }],
|
"robot3": ["robot3@file:packages/robot3", { "devDependencies": { "rollup": "^1.21.4", "terser": "^5.16.1" } }],
|
||||||
|
|
||||||
|
|
@ -67,8 +67,6 @@
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|
||||||
"zod": ["zod@4.3.4", "", {}, "sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A=="],
|
|
||||||
|
|
||||||
"terser/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
"terser/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
package.json
12
package.json
|
|
@ -7,18 +7,16 @@
|
||||||
"start": "bun run src/operator.ts"
|
"start": "bun run src/operator.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "1.3.5",
|
"@types/bun": "latest",
|
||||||
"prettier": "^3.7.4"
|
"prettier": "^3.6.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/generative-ai": "^0.24.1",
|
"hono": "^4.10.4",
|
||||||
"hono": "^4.11.3",
|
"openai": "^6.9.0",
|
||||||
"openai": "^6.15.0",
|
"robot3": "./packages/robot3"
|
||||||
"robot3": "./packages/robot3",
|
|
||||||
"zod": "^4.3.4"
|
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"semi": false,
|
"semi": false,
|
||||||
|
|
|
||||||
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
|
||||||
135
src/phone.ts
135
src/phone.ts
|
|
@ -16,9 +16,8 @@ import Buzz from "./buzz"
|
||||||
import { join } from "path"
|
import { join } from "path"
|
||||||
import GPIO from "./pins"
|
import GPIO from "./pins"
|
||||||
import { Agent } from "./agent"
|
import { Agent } from "./agent"
|
||||||
import { searchWeb, vestaboard } from "./agent/tools"
|
import { searchWeb } from "./agent/tools"
|
||||||
import { ring } from "./utils"
|
import { ring } from "./utils"
|
||||||
import { getTwilioAccountInfo, formatTwilioError } from "./utils/twilio"
|
|
||||||
import { getSound, WaitingSounds } from "./utils/waiting-sounds"
|
import { getSound, WaitingSounds } from "./utils/waiting-sounds"
|
||||||
|
|
||||||
type CancelableTask = () => void
|
type CancelableTask = () => void
|
||||||
|
|
@ -33,6 +32,7 @@ type PhoneContext = {
|
||||||
ringer: GPIO.Output
|
ringer: GPIO.Output
|
||||||
agentId: string
|
agentId: string
|
||||||
agentKey: string
|
agentKey: string
|
||||||
|
dialFailureReason?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type PhoneService = Service<typeof phoneMachine>
|
type PhoneService = Service<typeof phoneMachine>
|
||||||
|
|
@ -48,6 +48,7 @@ export const runPhone = async (agentId: string, agentKey: string) => {
|
||||||
using rotaryNumber = gpio.input(23, { pull: "up", debounce: 3 })
|
using rotaryNumber = gpio.input(23, { pull: "up", debounce: 3 })
|
||||||
|
|
||||||
await Buzz.setVolume(0.3)
|
await Buzz.setVolume(0.3)
|
||||||
|
log(`📞 Phone is ${hook.value ? "off hook" : "on hook"}`)
|
||||||
|
|
||||||
playStartRing(ringer)
|
playStartRing(ringer)
|
||||||
|
|
||||||
|
|
@ -71,6 +72,7 @@ const listenForPhoneEvents = (
|
||||||
) => {
|
) => {
|
||||||
hook.onChange((event) => {
|
hook.onChange((event) => {
|
||||||
const type = event.value == 0 ? "hang-up" : "pick-up"
|
const type = event.value == 0 ? "hang-up" : "pick-up"
|
||||||
|
log(`📞 Hook ${event.value} sending ${type}`)
|
||||||
phoneService.send({ type })
|
phoneService.send({ type })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -94,7 +96,7 @@ const startBaresip = async (phoneService: PhoneService, hook: GPIO.Input, ringer
|
||||||
const baresip = new Baresip(["/usr/bin/baresip", "-v", "-f", baresipConfig])
|
const baresip = new Baresip(["/usr/bin/baresip", "-v", "-f", baresipConfig])
|
||||||
|
|
||||||
baresip.registrationSuccess.on(async () => {
|
baresip.registrationSuccess.on(async () => {
|
||||||
log.debug("🐻 server connected")
|
log("🐻 server connected")
|
||||||
if (hook.value === 0) {
|
if (hook.value === 0) {
|
||||||
phoneService.send({ type: "initialized" })
|
phoneService.send({ type: "initialized" })
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -103,44 +105,35 @@ const startBaresip = async (phoneService: PhoneService, hook: GPIO.Input, ringer
|
||||||
})
|
})
|
||||||
|
|
||||||
baresip.callReceived.on(({ contact }) => {
|
baresip.callReceived.on(({ contact }) => {
|
||||||
log.debug(`🐻 incoming call from ${contact}`)
|
log(`🐻 incoming call from ${contact}`)
|
||||||
phoneService.send({ type: "incoming-call", from: contact })
|
phoneService.send({ type: "incoming-call", from: contact })
|
||||||
})
|
})
|
||||||
|
|
||||||
baresip.callEstablished.on(({ contact }) => {
|
baresip.callEstablished.on(({ contact }) => {
|
||||||
log.debug(`🐻 call established with ${contact}`)
|
log(`🐻 call established with ${contact}`)
|
||||||
phoneService.send({ type: "answered" } as any)
|
phoneService.send({ type: "answered" } as any)
|
||||||
})
|
})
|
||||||
|
|
||||||
baresip.hungUp.on(() => {
|
baresip.hungUp.on(() => {
|
||||||
log.debug("🐻 call hung up")
|
log("🐻 call hung up")
|
||||||
phoneService.send({ type: "remote-hang-up" })
|
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) => {
|
baresip.connect().catch((error) => {
|
||||||
log.error("🐻 connection error:", error)
|
log.error("🐻 connection error:", error)
|
||||||
phoneService.send({ type: "error", message: error.message })
|
phoneService.send({ type: "error", message: error.message })
|
||||||
})
|
})
|
||||||
|
|
||||||
baresip.error.on(async ({ message, statusCode, reason }) => {
|
baresip.error.on(async ({ message, statusCode, reason }) => {
|
||||||
let errorMessage = message
|
const errorMessage = statusCode ? `Registration failed: ${statusCode} ${reason}` : message
|
||||||
|
|
||||||
if (statusCode) {
|
|
||||||
const twilioInfo = await getTwilioAccountInfo()
|
|
||||||
if (twilioInfo && twilioInfo.status !== "active") {
|
|
||||||
errorMessage = formatTwilioError(twilioInfo)
|
|
||||||
} else {
|
|
||||||
errorMessage = `Registration failed: ${statusCode} ${reason}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.error("🐻 error:", errorMessage)
|
log.error("🐻 error:", errorMessage)
|
||||||
phoneService.send({ type: "error", message: errorMessage })
|
// Don't send error to state machine - we're retrying, not giving up
|
||||||
|
|
||||||
// for (let i = 0; i < 4; i++) {
|
|
||||||
// await ring(ringer, 500)
|
|
||||||
// await sleep(250)
|
|
||||||
// }
|
|
||||||
|
|
||||||
log("🔄 Retrying registration in 2 minutes...")
|
log("🔄 Retrying registration in 2 minutes...")
|
||||||
await sleep(2 * 60 * 1000)
|
await sleep(2 * 60 * 1000)
|
||||||
|
|
@ -179,7 +172,7 @@ const config = (
|
||||||
return ctx
|
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()
|
let streamPlayback = player.playStream()
|
||||||
|
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
|
|
@ -187,12 +180,14 @@ const startAgent = (service: Service<typeof phoneMachine>, ctx: PhoneContext) =>
|
||||||
apiKey: ctx.agentKey,
|
apiKey: ctx.agentKey,
|
||||||
tools: {
|
tools: {
|
||||||
search_web: (args: { query: string }) => searchWeb(args.query),
|
search_web: (args: { query: string }) => searchWeb(args.query),
|
||||||
vestaboard: (args: { query: string }) => vestaboard(args.query),
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
handleAgentEvents(service, agent, streamPlayback)
|
handleAgentEvents(service, agent, streamPlayback)
|
||||||
const stopListening = startListening(service, agent)
|
|
||||||
|
const stopListening = hasDialFailure
|
||||||
|
? await startListeningAfterDialFailure(agent, ctx.dialFailureReason)
|
||||||
|
: startListening(service, agent)
|
||||||
|
|
||||||
ctx.stopAgent = () => {
|
ctx.stopAgent = () => {
|
||||||
stopListening()
|
stopListening()
|
||||||
|
|
@ -203,7 +198,7 @@ const startAgent = (service: Service<typeof phoneMachine>, ctx: PhoneContext) =>
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
const startListening = (service: Service<typeof phoneMachine>, agent: Agent) => {
|
function startListening(service: Service<typeof phoneMachine>, agent: Agent) {
|
||||||
const abortAgent = new AbortController()
|
const abortAgent = new AbortController()
|
||||||
|
|
||||||
new Promise<void>(async (resolve) => {
|
new Promise<void>(async (resolve) => {
|
||||||
|
|
@ -258,6 +253,42 @@ const startListening = (service: Service<typeof phoneMachine>, agent: Agent) =>
|
||||||
return () => abortAgent.abort()
|
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 = (
|
const handleAgentEvents = (
|
||||||
service: Service<typeof phoneMachine>,
|
service: Service<typeof phoneMachine>,
|
||||||
agent: Agent,
|
agent: Agent,
|
||||||
|
|
@ -313,8 +344,6 @@ const handleAgentEvents = (
|
||||||
|
|
||||||
case "error":
|
case "error":
|
||||||
log.error("🤖 Agent error:", event.error)
|
log.error("🤖 Agent error:", event.error)
|
||||||
streamPlayback?.stop()
|
|
||||||
service.send({ type: "remote-hang-up" })
|
|
||||||
break
|
break
|
||||||
|
|
||||||
case "ping":
|
case "ping":
|
||||||
|
|
@ -339,6 +368,7 @@ const incomingCall = (ctx: PhoneContext, event: { type: "incoming-call"; from?:
|
||||||
}
|
}
|
||||||
|
|
||||||
const hangUp = (ctx: PhoneContext) => {
|
const hangUp = (ctx: PhoneContext) => {
|
||||||
|
console.log(`📞 Hanging up call`)
|
||||||
ctx.baresip.hangUp()
|
ctx.baresip.hangUp()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -347,6 +377,24 @@ const answerCall = (ctx: PhoneContext) => {
|
||||||
ctx.baresip.accept()
|
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) => {
|
const makeCall = async (ctx: PhoneContext) => {
|
||||||
log(`Dialing number: ${ctx.numberDialed}`)
|
log(`Dialing number: ${ctx.numberDialed}`)
|
||||||
if (ctx.numberDialed === 1) {
|
if (ctx.numberDialed === 1) {
|
||||||
|
|
@ -382,10 +430,13 @@ const stopRinger = (ctx: PhoneContext) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startDialToneAndAgent(this: any, ctx: PhoneContext) {
|
async function startDialToneAndAgent(this: any, ctx: PhoneContext) {
|
||||||
ctx = await startAgent(this, ctx)
|
const hasDialFailure = !!ctx.dialFailureReason
|
||||||
|
ctx = await startAgent(this, ctx, hasDialFailure)
|
||||||
|
|
||||||
|
if (!hasDialFailure) {
|
||||||
await dialTonePlayback?.stop()
|
await dialTonePlayback?.stop()
|
||||||
dialTonePlayback = await player.playTone([350, 440], Infinity)
|
dialTonePlayback = await player.playTone([350, 440], Infinity)
|
||||||
|
}
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
@ -405,12 +456,19 @@ const digitIncrement = (ctx: PhoneContext) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const playStartRing = async (ringer: GPIO.Output) => {
|
const playStartRing = async (ringer: GPIO.Output) => {
|
||||||
|
// Three quick beeps, getting faster = energetic/welcoming
|
||||||
ringer.value = 1
|
ringer.value = 1
|
||||||
await Bun.sleep(500)
|
await Bun.sleep(80)
|
||||||
ringer.value = 0
|
ringer.value = 0
|
||||||
await Bun.sleep(500)
|
await Bun.sleep(120)
|
||||||
|
|
||||||
ringer.value = 1
|
ringer.value = 1
|
||||||
await Bun.sleep(1000)
|
await Bun.sleep(80)
|
||||||
|
ringer.value = 0
|
||||||
|
await Bun.sleep(100)
|
||||||
|
|
||||||
|
ringer.value = 1
|
||||||
|
await Bun.sleep(80)
|
||||||
ringer.value = 0
|
ringer.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -443,18 +501,19 @@ const phoneMachine = createMachine(
|
||||||
t("remote-hang-up", "ready"),
|
t("remote-hang-up", "ready"),
|
||||||
t("hang-up", "idle", a(hangUp))),
|
t("hang-up", "idle", a(hangUp))),
|
||||||
ready: invoke(startDialToneAndAgent,
|
ready: invoke(startDialToneAndAgent,
|
||||||
t("dial-start", "dialing", a(stopDialTone), r(dialStart), a(stopAgent)),
|
t("dial-start", "dialing", a(stopDialTone), r(dialStart), r(clearDialFailure), a(stopAgent)),
|
||||||
t("hang-up", "idle", a(stopDialTone), a(stopAgent)),
|
t("hang-up", "idle", a(stopDialTone), r(clearDialFailure), a(stopAgent)),
|
||||||
t("start-agent", "connectToAgent", a(stopDialTone))),
|
t("start-agent", "connectToAgent", a(stopDialTone), r(clearDialFailure))),
|
||||||
connectToAgent: state(
|
connectToAgent: state(
|
||||||
t("hang-up", "idle", r(stopAgent)),
|
t("hang-up", "idle", r(stopAgent), r(clearDialFailure)),
|
||||||
t("remote-hang-up", "ready", r(stopAgent))),
|
t("remote-hang-up", "ready", r(stopAgent), r(clearDialFailure))),
|
||||||
dialing: state(
|
dialing: state(
|
||||||
t("dial-stop", "outgoing"),
|
t("dial-stop", "outgoing"),
|
||||||
t("digit_increment", "dialing", r(digitIncrement)),
|
t("digit_increment", "dialing", r(digitIncrement)),
|
||||||
t("hang-up", "idle")),
|
t("hang-up", "idle")),
|
||||||
outgoing: invoke(makeCall,
|
outgoing: invoke(makeCall,
|
||||||
t("answered", "connected"),
|
t("answered", "connected"),
|
||||||
|
t("dial-failed", "ready", r(storeDialFailure)),
|
||||||
t("hang-up", "idle", a(hangUp))),
|
t("hang-up", "idle", a(hangUp))),
|
||||||
aborted: state(
|
aborted: state(
|
||||||
t("hang-up", "idle")),
|
t("hang-up", "idle")),
|
||||||
|
|
@ -463,5 +522,5 @@ const phoneMachine = createMachine(
|
||||||
)
|
)
|
||||||
|
|
||||||
d._onEnter = function (machine, to, state, prevState, event) {
|
d._onEnter = function (machine, to, state, prevState, event) {
|
||||||
log.debug(`📱 ${machine.current} -> ${to} (${(event as any).type})`)
|
log(`📱 ${machine.current} -> ${to} (${(event as any).type})`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,38 @@ export const LogsPage = ({ service, logs }: LogsPageProps) => (
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<pre style="margin-top: 1rem;">
|
<pre id="logs" style="margin-top: 1rem; max-height: 70vh; overflow-y: auto;">
|
||||||
<code>{logs.trim()}</code>
|
<code>{logs.trim()}</code>
|
||||||
</pre>
|
</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>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
|
import { streamSSE } from "hono/streaming"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
import { IndexPage } from "./components/IndexPage"
|
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
|
// Handle WiFi configuration submission
|
||||||
app.post("/save", async (c) => {
|
app.post("/save", async (c) => {
|
||||||
const formData = await c.req.parseBody()
|
const formData = await c.req.parseBody()
|
||||||
|
|
|
||||||
28
src/sip.ts
28
src/sip.ts
|
|
@ -5,9 +5,11 @@ import { processStdout, processStderr } from "./utils/stdio.ts"
|
||||||
export class Baresip {
|
export class Baresip {
|
||||||
baresipArgs: string[]
|
baresipArgs: string[]
|
||||||
process?: Bun.PipedSubprocess
|
process?: Bun.PipedSubprocess
|
||||||
|
registered = false
|
||||||
callEstablished = new Emitter<{ contact: string }>()
|
callEstablished = new Emitter<{ contact: string }>()
|
||||||
callReceived = new Emitter<{ contact: string }>()
|
callReceived = new Emitter<{ contact: string }>()
|
||||||
hungUp = new Emitter()
|
hungUp = new Emitter()
|
||||||
|
dialFailed = new Emitter<{ reason: string }>()
|
||||||
error = new Emitter<{ message: string; statusCode?: string; reason?: string }>()
|
error = new Emitter<{ message: string; statusCode?: string; reason?: string }>()
|
||||||
registrationSuccess = new Emitter()
|
registrationSuccess = new Emitter()
|
||||||
|
|
||||||
|
|
@ -39,8 +41,15 @@ export class Baresip {
|
||||||
executeCommand("a")
|
executeCommand("a")
|
||||||
}
|
}
|
||||||
|
|
||||||
dial(phoneNumber: string) {
|
async dial(phoneNumber: string) {
|
||||||
executeCommand(`d${phoneNumber}`)
|
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() {
|
hangUp() {
|
||||||
|
|
@ -52,6 +61,7 @@ export class Baresip {
|
||||||
this.callReceived.removeAllListeners()
|
this.callReceived.removeAllListeners()
|
||||||
this.hungUp.removeAllListeners()
|
this.hungUp.removeAllListeners()
|
||||||
this.registrationSuccess.removeAllListeners()
|
this.registrationSuccess.removeAllListeners()
|
||||||
|
this.dialFailed.removeAllListeners()
|
||||||
this.error.removeAllListeners()
|
this.error.removeAllListeners()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,6 +73,7 @@ export class Baresip {
|
||||||
}
|
}
|
||||||
|
|
||||||
async restart() {
|
async restart() {
|
||||||
|
this.registered = false
|
||||||
if (this.process) {
|
if (this.process) {
|
||||||
this.process.kill()
|
this.process.kill()
|
||||||
this.process = undefined
|
this.process = undefined
|
||||||
|
|
@ -97,6 +108,7 @@ export class Baresip {
|
||||||
|
|
||||||
const registrationSuccessMatch = line.match(/\[\d+ bindings?\]/)
|
const registrationSuccessMatch = line.match(/\[\d+ bindings?\]/)
|
||||||
if (registrationSuccessMatch) {
|
if (registrationSuccessMatch) {
|
||||||
|
this.registered = true
|
||||||
this.registrationSuccess.emit()
|
this.registrationSuccess.emit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,23 +117,31 @@ export class Baresip {
|
||||||
if (registrationFailedMatch) {
|
if (registrationFailedMatch) {
|
||||||
const [, statusCode, reason] = registrationFailedMatch
|
const [, statusCode, reason] = registrationFailedMatch
|
||||||
log.error(`Registration failed: ${statusCode} ${reason}`)
|
log.error(`Registration failed: ${statusCode} ${reason}`)
|
||||||
|
this.registered = false
|
||||||
this.error.emit({ message: line, statusCode, reason })
|
this.error.emit({ message: line, statusCode, reason })
|
||||||
} else if (socketInUseMatch) {
|
} else if (socketInUseMatch) {
|
||||||
log.error(`Registration failed: socket in use`)
|
log.error(`Registration failed: socket in use`)
|
||||||
|
this.registered = false
|
||||||
this.error.emit({ message: line })
|
this.error.emit({ message: line })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const executeCommand = async (command: string) => {
|
const executeCommand = async (command: string): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const url = new URL(`/?${command}`, "http://127.0.0.1:8000")
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error(`Error executing command: ${response.statusText}`)
|
throw new Error(`Error executing command: ${response.statusText}`)
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to execute command:", error)
|
log.error("Failed to execute command:", error)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
const accountSid = process.env.TWILIO_ACCOUNT_SID
|
|
||||||
const authToken = process.env.TWILIO_AUTH_TOKEN
|
|
||||||
|
|
||||||
type AccountStatus = "active" | "suspended" | "closed"
|
|
||||||
|
|
||||||
interface TwilioAccountInfo {
|
|
||||||
status: AccountStatus
|
|
||||||
balance?: string
|
|
||||||
currency?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getTwilioAccountInfo(): Promise<TwilioAccountInfo | undefined> {
|
|
||||||
if (!accountSid || !authToken) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const credentials = Buffer.from(`${accountSid}:${authToken}`).toString("base64")
|
|
||||||
const headers = { Authorization: `Basic ${credentials}` }
|
|
||||||
|
|
||||||
const [accountRes, balanceRes] = await Promise.all([
|
|
||||||
fetch(`https://api.twilio.com/2010-04-01/Accounts/${accountSid}.json`, { headers }),
|
|
||||||
fetch(`https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Balance.json`, { headers }),
|
|
||||||
])
|
|
||||||
|
|
||||||
if (!accountRes.ok) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const account = (await accountRes.json()) as { status: AccountStatus }
|
|
||||||
const info: TwilioAccountInfo = { status: account.status }
|
|
||||||
|
|
||||||
if (balanceRes.ok) {
|
|
||||||
const balance = (await balanceRes.json()) as { balance: string; currency: string }
|
|
||||||
info.balance = balance.balance
|
|
||||||
info.currency = balance.currency
|
|
||||||
}
|
|
||||||
|
|
||||||
return info
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatTwilioError(info: TwilioAccountInfo): string {
|
|
||||||
if (info.status === "suspended") {
|
|
||||||
const balanceInfo = info.balance ? ` (balance: ${info.balance} ${info.currency})` : ""
|
|
||||||
return `Twilio account suspended${balanceInfo} - add funds at twilio.com/console`
|
|
||||||
}
|
|
||||||
if (info.status === "closed") {
|
|
||||||
return "Twilio account is closed"
|
|
||||||
}
|
|
||||||
return `Twilio account status: ${info.status}`
|
|
||||||
}
|
|
||||||
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