Raymond CamdenFather, husband, developer relations and web standards expert, and cat demo builder.2024-03-17T17:28:36+00:00Raymond Camdenraymondcamden@gmail.comhttps://www.raymondcamden.com/feed.xmlEleventyhttps://www.raymondcamden.com/2024/03/17/links-for-youLinks For You2024-03-17T18:00:00+00:00
<p>Good morning. I managed to tear myself away from Assasin's Creed Valhalla a bit to get out a "Links For You" post. I was supposed to do this last week (my schedule is every two weeks), but I must have forgotten. Or just been busy. Life happens, amiright??!?! Next month will be pretty busy as well. I've got two conferences I'll be speaking at, a birthday, and just normal life stuff as well. As a reminder, if you find these posts, and this blog handy, I've got multiple ways you can show your support. With my birthday coming up, you could always visit my <a href="http://www.amazon.com/gp/registry/wishlist/2TCL1D08EZEYE/ref=cm_wl_rlist_go_v?">Amazon wishlist</a>, or become a <a href="https://www.patreon.com/raymondcamden">patron</a> (I'd use that to possibly invest in better analytics), or simply <a href="https://www.buymeacoffee.com/raymondcamden">buy me a coffee</a>. Anything and everything is much appreciated. :) Ok, enough begging, here are the links for this edition.</p><h2 id="webshare-web-component" tabindex="-1">WebShare Web Component <a class="header-anchor" href="#webshare-web-component">#</a></h2><p>I played around with the <a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API">Web Share API</a> almost a year ago now (<a href="https://www.raymondcamden.com/2023/04/20/testing-the-web-share-api">"Testing the Web Share API"</a>), and while I thought it was pretty neat, I haven't looked at it recently. Turns out, it's an excellent candidate for progressive enhancement and web components. Zach Leat built the very cool <a href="https://www.zachleat.com/web/webcare-webshare/"><code><webcare-webshare></code></a> component that lets you use Web Share when available but gracefully fall back to a simple link. You can play with the <a href="https://zachleat.github.io/webcare-webshare/demo.html">demo</a> or check out the <a href="https://github.com/zachleat/webcare-webshare">repo</a>. (And as always, whenever I post to stuff like this, I love to hear about people using it so if you do decide to implement it, leave me a comment below.)</p><h2 id="quick-and-simple-typescript-with-node-tutorial" tabindex="-1">Quick and Simple Typescript with Node Tutorial <a class="header-anchor" href="#quick-and-simple-typescript-with-node-tutorial">#</a></h2><p>Next up is a quick tutorial for quickly getting started with TypeScript and Node.js: <a href="https://www.learnwithjason.dev/blog/modern-node-server-typescript-2024">"How to set up a Node server with TypeScript in 2024"</a>. Don't let the title confuse you though, the tutorial can be used for a Node "server" (i.e. something that keeps running, usually responding on HTTP) as well as a Node script that just runs and is done. I've only used TypeScript rarely, but this post was incredibly easy to understand and quick to test. Also, I learned something new. The "watch" command in Node can be used for scripts that aren't servers. It simply reruns the script for you. That's <em>really</em> useful and I wish I had known that before!</p><p>Credit for this post goes to <a href="https://www.learnwithjason.dev/blog/modern-node-server-typescript-2024">Jason Lengstorf</a>.</p><h2 id="quickly-mock-a-rest-api-with-json-server" tabindex="-1">Quickly Mock a REST API with json-server <a class="header-anchor" href="#quickly-mock-a-rest-api-with-json-server">#</a></h2><p>For the last link, here's a super useful CLI tool. <code>json-server</code> lets you set up a mock API using just a JSON file. The CLI reads the file and based on the data there, sets up multiple different API routes and features. Heck, it can even update the JSON file based on <code>PUT</code> and <code>DELETE</code> calls. It is <em>super</em> flexible and neat and can be great in cases where you don't want to setup a database and a proper server, just mock up some REST API calls quickly. Read more about it here: <a href="https://github.com/typicode/json-server/tree/v0">https://github.com/typicode/json-server/tree/v0</a></p><p>Back in January, I did a quick video on it. Check it out below:</p><iframe width="560" height="315" src="https://www.youtube.com/embed/xs56HGC5Y5I?si=ziHEAwwVeRGEoSXR" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen style="display:block;margin:auto;margin-bottom: 15px"></iframe>
Raymond Camdenraymondcamden@gmail.comhttps://www.raymondcamden.com/2024/03/13/responding-to-html-changes-in-a-web-componentResponding to HTML Changes in a Web Component2024-03-13T18:00:00+00:00
<p>While driving my kids to school this morning, I had an interesting thought. Is it possible for a web component to recognize, and respond, when its inner DOM contents have changed? Turns out of course it is, and the answer isn't really depenedant on web components, but is a baked-in part of the web platform, the <a href="https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver">MutationObserver</a>. Here's what I built as a way to test it out.</p><h2 id="the-initial-web-component" tabindex="-1">The Initial Web Component <a class="header-anchor" href="#the-initial-web-component">#</a></h2><p>I began with a simple web component that had the following simple feature - count the number of images inside it and report. So we can start with this HTML:</p><pre><code class="language-html"><img-counter> <p> <img src="https://placehold.co/60x40"> </p> <div> <img src="https://placehold.co/40x40"> </div> <img src="https://placehold.co/90x90"></img-counter></code></pre><p>And build a simple component:</p><pre><code class="language-js">class ImgCounter extends HTMLElement { constructor() { super(); } connectedCallback() { let imgs = this.querySelectorAll('img'); this.innerHTML += `<p>There are <strong>${imgs.length}</strong> images in me.</p>`; } }if(!customElements.get('img-counter')) customElements.define('img-counter', ImgCounter);</code></pre><p>It just uses <code>querySelectorAll</code> to count the <code>img</code> node inside it. For my initial HTML, this reports 3 of course.</p><p>I then added a simple button to my HTML:</p><pre><code class="language-html"><button id="testAdd">Add Img</button></code></pre><p>And a bit of code:</p><pre><code class="language-js">document.querySelector('#testAdd').addEventListener('click', () => { document.querySelector('img-counter').innerHTML = '<p>New: <img src="https://placehold.co/100x100"></p>' + document.querySelector('img-counter').innerHTML;});</code></pre><p>When run, it will add a new image, but obviously, the counter won't update. Here's a CodePen of this initial version:</p><p class="codepen" data-height="500" data-theme-id="dark" data-default-tab="js,result" data-slug-hash="ZEZOwKO" data-editable="true" data-user="cfjedimaster" style="height: 500px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;"> <span>See the Pen <a href="https://codepen.io/cfjedimaster/pen/ZEZOwKO"> Img Counter WC 1</a> by Raymond Camden (<a href="https://codepen.io/cfjedimaster">@cfjedimaster</a>) on <a href="https://codepen.io">CodePen</a>.</span></p><script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script><h2 id="enter---the-mutationobserver" tabindex="-1">Enter - the MutationObserver <a class="header-anchor" href="#enter---the-mutationobserver">#</a></h2><p>The MDN docs on <a href="https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver">MutationObserver</a> are pretty good, as always. I won't repeat what's written there but the basics are:</p><ul><li>Define what you want to observe under a DOM element - which includes the subtree, childList, and attributes</li><li>Write your callback</li><li>Define the observer based on the callback</li><li>Tie the observer to the DOM root you want to watch</li></ul><p>So my thinking was...</p><ul><li>Move out my 'count images and update display' to a function</li><li>Add a mutation observer and when things change, re-run the new function</li></ul><p>My first attempt was rather naive, but here it is in code form, not CodePen, for reasons that will be clear soon:</p><pre><code class="language-js">class ImgCounter extends HTMLElement { constructor() { super(); } connectedCallback() { this.renderCount(); const mutationObserver = (mutationList, observer) => { this.renderCount(); }; const observer = new MutationObserver(mutationObserver); observer.observe(this, { childList: true, subtree: true }); } renderCount() { let imgs = this.querySelectorAll('img'); this.innerHTML += `<div><p>There are <strong>${imgs.length}</strong> images in me.</p></div>`; }}</code></pre><p>So the MutationObserver callback is sent information about what changed, and in my simple little mind, I figured, I don't care. If something changes, just rerun the count to count images.</p><p>Look at that code and see if you can figure out the issue. If you can, leave me a comment below.</p><p>So yes, this "worked", but this is what happened:</p><ul><li>I clicked the button to add a new image</li><li>The mutation observer fired and was like, cool, new shit to do, run <code>renderCount</code></li><li><code>renderCount</code> got the images and updated the HTML to reflect the new count</li><li>Hey guess what, <code>renderCount</code> changed the DOM tree, let's run the observer again</li><li>Repeat until the <a href="https://en.wikipedia.org/wiki/Heat_death_of_the_universe">heat death of the universe</a></li></ul><p>I had to tweak things a bit, but here's the final version, and I'll explain what I did:</p><pre><code class="language-js">class ImgCounter extends HTMLElement { #myObserver; constructor() { super(); } connectedCallback() { // create the div we'll use to monitor images: this.innerHTML += '<div id="imgcountertext"></div>'; this.renderCount(); const mutationObserver = (mutationList, observer) => { for(const m of mutationList) { if(m.target === this) { this.renderCount(); } } }; this.myObserver = new MutationObserver(mutationObserver); this.myObserver.observe(this, { childList: true, subtree: true }); } disconnectedCallback() { this.myObserver.disconnect(); } renderCount() { let imgs = this.querySelectorAll('img'); this.querySelector('#imgcountertext').innerHTML = `There are <strong>${imgs.length}</strong> images in me.`; }}if(!customElements.get('img-counter')) customElements.define('img-counter', ImgCounter);document.querySelector('#testAdd').addEventListener('click', () => { document.querySelector('img-counter').innerHTML = '<p>New: <img src="https://placehold.co/100x100"></p>' + document.querySelector('img-counter').innerHTML;});</code></pre><p>I initially had said I didn't care about what was in the list of items changed in the mutation observer, but I noticed that the <code>target</code> value was different when I specifically added my image count report. To help with this, I'm now using a <code>div</code> tag with an ID and <code>renderCount</code> modifies that.</p><p>When a new image (or anything) is added directly inside the component, my target value is <code>img-counter</code>, or <code>this</code>, which means I can run <code>renderCount</code> on it. When <code>renderCount</code> runs, the target of the mutation is its own div.</p><p>Also, I noticed that the MutationObserver talks specifically called out the <code>disconnect</code> method as a way of ending the DOM observation. That feels pretty important, and web components make it easy with the <code>disconnectedCallback</code> method.</p><p>All in all, it works well now (as far as I know ;), and you can test it yourself below:</p><p class="codepen" data-height="500" data-theme-id="dark" data-default-tab="js,result" data-slug-hash="dyLXaWm" data-editable="true" data-user="cfjedimaster" style="height: 500px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;"> <span>See the Pen <a href="https://codepen.io/cfjedimaster/pen/dyLXaWm"> Img Counter WC 2</a> by Raymond Camden (<a href="https://codepen.io/cfjedimaster">@cfjedimaster</a>) on <a href="https://codepen.io">CodePen</a>.</span></p><script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script><p>Remember, MutationObserver can absolutely be used outside of web components. Also note that if you only want to respond to an <em>attribute</em> change in a web component, that's really easy as it's baked into the spec. As always, let me know what you think, and I've got a strong feeling that someone going to show me a better way of doing this, and I'd be happy to see it!</p>
Raymond Camdenraymondcamden@gmail.comhttps://www.raymondcamden.com/2024/03/07/using-intlrelativetimeformat-for-localized-relative-timingsUsing Intl.RelativeTimeFormat for Localized Relative Timings2024-03-07T18:00:00+00:00
<p>I've been singing the praises of the web platform's <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl">Intl</a> object for years now, but it still continues to impress me. While I've seen it before, today I came across the <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat">RelativeTimeFormat</a> API which looks absolutely fabulous. I played with it a bit and thought I'd share some tips.</p><h2 id="the-basics" tabindex="-1">The Basics <a class="header-anchor" href="#the-basics">#</a></h2><p>The <code>RelativeTimeFormat</code> API works like so:</p><ul><li>Given a locale...</li><li>Given a difference (in either a positive or negative value)...</li><li>And given a unit of time, like 'hour' (or 'hours')</li></ul><p>Report the difference in the user's desired language. So for example:</p><ul><li>3 and day, "in 3 days"</li><li>-1 and day, "one day ago"</li><li>18 and hour, "in 18 hours"</li></ul><p>Also, you can specify whether or not to always have a numeric answer. This comes into play when the value of 1 is used. So for example, if you specify 1 and 'day', you can either get, "in 1 day", or "tomorrow".</p><p>Here's an example in code:</p><pre><code class="language-js">const rtf1 = new Intl.RelativeTimeFormat('en');let diffInDays = rtf1.format(2, 'days');</code></pre><p>Will return 'in 2 days'. Simple, right? Let's look at the options, and remember the <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat/RelativeTimeFormat">MDN doc</a> is your best bet for a full description.</p><p><code>RelativeTimeFormat</code> takes a second optional argument for options. Those options include:</p><ul><li>localMatcher - which gives you more control over what locale is used</li><li>numberingSystem - lets you switch to different ways of representing numbers</li><li>style - can be <code>long</code>, <code>short</code>, or <code>narrow</code> and modifies the output to, well, be shorter. <code>long</code> is the default, but in <code>short</code> you will see things like 'mo.' instead of 'month'.</li><li>numeric - I mentioned this above. It can be <code>always</code>, the default, or <code>auto</code>, and when set to <code>auto</code> you'll see stuff like "next month", or "yesterday".</li></ul><p>The <code>format</code> method only takes two arguments:</p><ul><li>The number, where a negative value represents the past</li><li>The unit, one of: 'year', 'quarter', 'month', 'week', 'day', 'minute', and 'second'. You can also use the plural forms.</li></ul><p><code>RelativeTimeFormat</code> also has a few other related methods you may need I won't cover today, but check the <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat">docs</a> for info.</p><h2 id="the-simple-demo" tabindex="-1">The Simple Demo <a class="header-anchor" href="#the-simple-demo">#</a></h2><p>I wanted to play with this myself, so I built a quick demo in CodePen. As a quick aside, when I build demos in CodePen for my blog, I try to avoid using <code>console</code> as you can't see it in the embed. To make it easier to share the outputs with you, I used an empty div and this little bit of JavaScript:</p><pre><code class="language-js">const $log = document.querySelector('#log');const log = s => { $log.innerHTML += s + '<br/>';};</code></pre><p>This then lets me do stuff like <code>log('here is the result', foo)</code>.</p><p>With that out of the way, here's the embed, and you can see two formatters in play - one using the defaults, and one using <code>numeric=auto</code> to show you the difference. Also, note that '0' is a valid value for the difference.</p><p class="codepen" data-height="500" data-theme-id="dark" data-default-tab="js,result" data-slug-hash="oNObNNX" data-editable="true" data-user="cfjedimaster" style="height: 500px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;"> <span>See the Pen <a href="https://codepen.io/cfjedimaster/pen/oNObNNX"> Intl.RelativeTimeFormat</a> by Raymond Camden (<a href="https://codepen.io/cfjedimaster">@cfjedimaster</a>) on <a href="https://codepen.io">CodePen</a>.</span></p><script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script><p>Easy, right?</p><p><img src="https://static.raymondcamden.com/images/2024/03/notsofast.webp" alt="Not So Fast" class="imgborder imgcenter" loading="lazy"></p><h2 id="using-relativetimeformat-in-the-real-world" tabindex="-1">Using RelativeTimeFormat in the Real World <a class="header-anchor" href="#using-relativetimeformat-in-the-real-world">#</a></h2><p>I think you can see this is an easy API to use, but how do you actually use it with, you know, real data? What I mean is this. Given two dates, what makes sense for the unit?</p><p>For example:</p><ul><li>3/7/2024 8:00 AM and 3/7/2024 9:20 AM: Do we use minutes or hours? I'd say hours, but I could see minutes being useful.</li><li>3/7/2024 8:00 AM and 3/6/2023 2:00 PM: Do we use days? Do we use hours?</li></ul><p>Also, I think the nature of your content also has a big impact on this decision. For a social media network, I think you would want as precise of a difference as possible: "Ray's Cat posted 'X' one minute ago." Or heck, maybe even to the second: "Ray's Cat posted 'X' 50 seconds ago."</p><p>But would that make sense for a blog, or press release? If you read this post an hour after I post it, I'm probably fine with the difference being reported as 'Today' versus 'One hour ago', or '50 minutes ago'.</p><p>To handle this, we need to first figure out what makes sense for our content, and then handle the technical aspect.</p><p>Like most things in life, the 'solution' here will come down to...</p><p><img src="https://static.raymondcamden.com/images/2024/03/itdepends.jpg" alt="It Depends" class="imgborder imgcenter" loading="lazy"></p><p>Here's one take on handling it.</p><p>Given two dates, in this case, a user selected date and right now, I'm going to do the following:</p><ul><li>Get the difference in milliseconds between the selected date and now.</li><li>Figure out an appropriate unit. If the difference is less than a minute, use seconds. If less than an hour, use minutes. And so forth.</li><li>Then, convert the difference, which was milliseconds, to the value that makes sense for the unit.</li></ul><p>Whew, got that?</p><p>I began with this HTML:</p><pre><code class="language-html"><p><label for="otherDate"> Select the date, and a relative value will be passed:</label><input type="datetime-local" id="otherDate"></p><p> Relative value: <span id="output"></span></p></code></pre><p>Note I'm using <code>datetime-local</code> as my input type so I can pick both dates and times. Now for the code.</p><p>First, some constants:</p><pre><code class="language-js">const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });const $output = document.querySelector('#output');const $otherDate = document.querySelector('#otherDate');</code></pre><p>Then I added a <code>change</code> handler to the datetime field:</p><pre><code class="language-js">$otherDate.addEventListener('change', () => { let date = new Date($otherDate.value); let now = new Date(); let diff = date - now; // negative values are in the past, positive in the future let unit = determineUnit(diff); console.log(`for ${diff}, unit is ${unit}`); let inUnit = toUnit(diff, unit); console.log(`value in unit ${inUnit}`); $output.innerText = `${rtf.format(inUnit, unit)}`});</code></pre><p>As you can see, I get the date, get the difference based on right now, and then call two helper functions. The first determines the unit to use:</p><pre><code class="language-js">// Set of constants that represent how many ms per unitconst MINUTE = 60 * 1000;const HOUR = 60 * MINUTE;const DAY = 24 * HOUR;const WEEK = 7 * DAY;// yeah, this is probably not perfectconst MONTH = 4 * WEEK;const YEAR = MONTH * 12;function determineUnit(x) { x = Math.abs(x); if(x < MINUTE) return 'second'; if(x < HOUR) return 'minute'; if(x < DAY) return 'hour'; if(x < WEEK) return 'day'; if(x < MONTH) return 'week'; if(x < YEAR) return 'month'; return 'year';}</code></pre><p>By the way, I skipped <code>quarter</code>, but you could modify the code to support that.</p><p>Then I used this function to change the difference to the right value:</p><pre><code class="language-js">// given a value of x, how many of unit is it?function toUnit(x, unit) { if(unit === 'minute') return Math.round(x / MINUTE); if(unit === 'hour') return Math.round(x / HOUR); if(unit === 'day') return Math.round(x / DAY); if(unit === 'week') return Math.round(x / WEEK); if(unit === 'month') return Math.round(x / MONTH); if(unit === 'year') return Math.round(x / YEAR);}</code></pre><p>You can play with the demo below:</p><p class="codepen" data-height="300" data-theme-id="dark" data-default-tab="js,result" data-slug-hash="ZEZQEGV" data-editable="true" data-user="cfjedimaster" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;"> <span>See the Pen <a href="https://codepen.io/cfjedimaster/pen/ZEZQEGV"> Intl.RelativeTimeFormat</a> by Raymond Camden (<a href="https://codepen.io/cfjedimaster">@cfjedimaster</a>) on <a href="https://codepen.io">CodePen</a>.</span></p><script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script><p>This works, but brings up yet another issue! Imagine today is March 2nd and I selected February 28th. You could say, rightly, that was 3 days ago. You could also say last month. Last month is simpler, but kinda vague. But maybe months are <em>really</em> important in your content. Like, stuff/data/whatever from a previous month has some implications that are important. In that case, you would want to show 'last month', not '3 days ago'.</p><p>Again, it depends.</p><p>That being said, I <em>really</em> love Intl and it's one of my favorite parts of the web platform. I'd love to know if you've used this, or any part of Intl really. Leave me a comment below!</p>
Raymond Camdenraymondcamden@gmail.comhttps://www.raymondcamden.com/2024/03/04/converting-a-vue-2-app-to-alpinejsConverting a Vue 2 App to Alpine.js2024-03-04T18:00:00+00:00
<p>A little over two years ago I published an <a href="https://www.raymondcamden.com/2022/01/13/building-my-first-idle-clicker-game-in-vuejs">"idle clicker game"</a> built in Vue.js. I called it "IdleFleet" and was heavily inspired by games like <a href="https://adarkroom.doublespeakgames.com/">A Dark Room</a>, where I relied on simple text graphics and game mechanics that would change as you played. In my last <a href="https://www.youtube.com/watch?v=15SgRdJPdoE"><code><Code><Br></code></a> session, I walked through the process of building a simple text game and brought up IdleFleet as an example. While playing the game to refresh my memory about what I actually built... I discovered I actually really liked it. I decided it would be good to give it some attention with new features and other updates, but before I could do that, I knew I needed to switch from Vue to <a href="https://alpinejs.dev">Alpine.js</a>.</p><h2 id="why-vue-to-alpine%3F" tabindex="-1">Why Vue to Alpine? <a class="header-anchor" href="#why-vue-to-alpine%3F">#</a></h2><p>So, this is mostly my opinion, and feel free to skip to the next section, but as much as I respect Vue, I don't find it as appropriate these days for simpler web pages and non-"apps". I put "apps" in quotes because that means something different to different people. In general, when what you are building involves multiple different 'views' (a screen for X, a screen for Y), I generally consider that an app. A page with JavaScript for interactivity is simpler and Vue feels like overkill there. Alpine <em>really</em> fits the spot for these needs and that's part of the reason I've been so enamored of it the last year or so.</p><p>Also, and this is <em>really</em> now just an opinion, I kind of feel like Vue has lost some of its approachability it had in the older days. It's absolutely powerful, performant, and so forth, but I'm just finding myself a lot more comfortable with Alpine.</p><p>Ok, enough opinions, let's get into the process.</p><h2 id="the-previous-code" tabindex="-1">The Previous Code <a class="header-anchor" href="#the-previous-code">#</a></h2><p>Before I get started, you can browse the Vue version of the repository here: <a href="https://github.com/cfjedimaster/IdleFleet/tree/820f1bea20a33b6f9248ebdc687f9ce7c93235bf">https://github.com/cfjedimaster/IdleFleet/tree/820f1bea20a33b6f9248ebdc687f9ce7c93235bf</a>. My changes primarily revolve around two files: <code>index.html</code> and <code>app.js</code> (although I made a small change in <code>app.css</code> as well).</p><h2 id="library-change" tabindex="-1">Library Change <a class="header-anchor" href="#library-change">#</a></h2><p>The first change was the easiest, and resulted in hundreds of awesome console errors - swapping out the Vue CDN (<code><script src="https://unpkg.com/vue@2.6.14/dist/vue.js"></script></code>) for Alpine (<code><script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script></code>).</p><h2 id="cloak-change" tabindex="-1">Cloak Change <a class="header-anchor" href="#cloak-change">#</a></h2><p>Both Vue and Alpine support the idea of a 'cloak' that will hide all the content of your application until the application is loaded. In Vue, you add <code>v-cloak</code> to your top-level container for your application and then add CSS to hide it. In Alpine, you just change this to <code>x-cloak</code> and rename the directive in CSS:</p><pre><code class="language-css">[x-cloak] {display: none}</code></pre><h2 id="noting-the-app-container" tabindex="-1">Noting the App Container <a class="header-anchor" href="#noting-the-app-container">#</a></h2><p>Speaking of the app container, in a simple Vue app you could mark the 'area' where it would work with an ID and specify it in your Vue code with <code>el:"name of area"</code>, so for example, this in HTML:</p><pre><code class="language-html"><div id="app" v-cloak"></code></pre><p>And this in the JavaScript:</p><pre><code class="language-js">const app = new Vue({ el:'#app',</code></pre><p>In Alpine, you specify it in your HTML:</p><pre><code class="language-html"><div x-data="app" x-cloak></code></pre><p>And this is then referenced in JavaScript:</p><pre><code class="language-js">Alpine.data('app', () => ({</code></pre><h2 id="filter-removal" tabindex="-1">Filter Removal <a class="header-anchor" href="#filter-removal">#</a></h2><p>Filters were removed in Vue 3, so I removed it and some config stuff as well:</p><pre><code class="language-js">Vue.filter('number', s => { return numberFormat(s);});Vue.config.productionTip = false;Vue.config.devtools = false;</code></pre><p>The filter just called a function (<code>numberFormat</code>) defined later in the code. If your are curious, that function simply makes use of the awesome <code>Intl</code> API:</p><pre><code class="language-js">function numberFormat(s) { if(!window.Intl) return s; return new Intl.NumberFormat().format(s);}</code></pre><h2 id="main-application-updating" tabindex="-1">Main Application Updating <a class="header-anchor" href="#main-application-updating">#</a></h2><p>In the previous version, the <code>Vue</code> app defined variables in a <code>data key</code>, had a list of functions in <code>methods</code>, and computed values in, well, <code>computed</code>. Basically the <code>new Vue...</code> part wrapped an object where data, methods, and computed functions were defined in separate blocks, also the <code>init</code> function.</p><p>When you define an Alpine application, you also define a top-level object, but there's no separation, you just provide a list of key/value pairs where each value can be simple reactive data or a function. You can mix this up as much as you want.</p><p>That being said, I try to organize my Alpine applications by putting data on top, and then methods. I don't usually worry about splitting up computed methods versus regular methods, but due to the size of this application, I did.</p><p>So I start off with variables:</p><pre><code class="language-js"> Alpine.data('app', () => ({ ships:[], credits: constants.INITIAL_CREDITS, log:[], autoShip:false, autoShipFlipped:false, mercantileSkill: 1, mercantileFlipped: false, nextShipReturnTime:null, shipSpeed: 1, shipSpeedFlipped: false, messages:null, ceps:null, cepsFlipped:false, lastCEPS: constants.INITIAL_CREDITS,</code></pre><p>Then have my <code>init</code>:</p><pre><code class="language-js">async init() { this.addShip(); setInterval(() => { this.heartBeat() }, 1000); setInterval(() => { this.randomMsg() }, constants.RANDOM_MSG_INTERVAL * 1000); setInterval(() => { this.doAutoShip() }, constants.AUTO_SHIP_DURATION * 1000); //random events are not on intervals, but kick off first one 5ish minutes setTimeout(() => { this.randomEvent() }, (5000 * 60) + (getRandomInt(0,3000)*60)); // even though we dont show CEPS immediately, track immediately setInterval(() => { this.generateCEPS() }, constants.CEPS_DURATION * 1000); this.messages = await (await fetch('./messages.json')).json();},</code></pre><p>Then a set of methods, where I generally tried to use alphabetical sorting, with the exception of <code>heartBeat</code> as it's a pretty core method to the game.</p><p>Computed methods in Alpine are written as getters, but you don't have to specify the <code>get</code> keyword. I like doing so though as it makes it more obvious. Here's an example of two of them:</p><pre><code class="language-js">// getter sectionget availableShips() { return this.ships.filter(s => s.available);},get autoShipAllowed() { // only flip once if(this.credits > constants.ALLOW_AUTOSHIP) { this.autoShipFlipped = true; } return this.autoShipFlipped;},</code></pre><p>Just like Vue, Alpine will notice when data referenced in these methods are updated and rerun their logic for display.</p><h2 id="this-scope-binding" tabindex="-1">This Scope binding <a class="header-anchor" href="#this-scope-binding">#</a></h2><p>One issue I ran into that I've seen before in Alpine, was ensuring my <code>this</code> scope was properly referenced. So for example, in Vue, I had this in my start-up code:</p><pre><code class="language-js">setInterval(this.doAutoShip, AUTO_SHIP_DURATION * 1000);</code></pre><p>In Alpine, when <code>doAutoShip</code> ran it lost access to the <code>this</code> scope variables. I tweaked them all like so:</p><pre><code class="language-js">setInterval(() => { this.doAutoShip() }, constants.AUTO_SHIP_DURATION * 1000);</code></pre><h2 id="html-updates---variables" tabindex="-1">HTML Updates - Variables <a class="header-anchor" href="#html-updates---variables">#</a></h2><p>In Vue, you can add references to variables with brackets, so for example:</p><pre><code class="language-html">Total Fleet Size: {{ fleetSize }}<br/></code></pre><p>Alpine requires you to use <code>x-text</code> or <code>x-html</code>, so I switched these to:</p><pre><code class="language-html">Total Fleet Size: <span x-text="fleetSize"></span><br/></code></pre><p>It's a bit more verbose and bugs me a tiny bit, but I got over it.</p><h2 id="html-updates---conditions" tabindex="-1">HTML Updates - Conditions <a class="header-anchor" href="#html-updates---conditions">#</a></h2><p>Vue supports <code>v-if</code> and <code>v-show</code> and in Alpine this, yep, <code>x-if</code> and <code>x-show</code>. I switched my <code>v-if</code> statements to <code>x-show</code>. Another issue is that Alpine doesn't support <code>x-else</code>. In the one case where I needed it, I just used a condition with a negative (<code>!</code>) in front of it.</p><pre><code class="language-html"><button @click="enableAutoShip" x-show="autoShipAllowed" title="If enabled, this will periodically send out available ships."><span x-show="autoShip">Auto Ship Enabled</span><span x-show="!autoShip">Auto Ship Disabled</span></button></code></pre><h2 id="html-updates---refs" tabindex="-1">HTML Updates - Refs <a class="header-anchor" href="#html-updates---refs">#</a></h2><p>Both Vue and Alpine support the ability to specify a 'ref' value in HTML that can then be referenced in code later. It gives you a pointer to the DOM. In Vue, this was done with <code>ref</code>, but in Alpine it's <code>x-ref</code>. Easy enough, right?</p><h2 id="html-updates---numberformat" tabindex="-1">HTML Updates - numberFormat <a class="header-anchor" href="#html-updates---numberformat">#</a></h2><p>Previous, I used my number format in HTML like so:</p><pre><code class="language-html">Credits: {{ credits | number }}</code></pre><p>For Alpine, I moved <code>numberFormat</code> into the Alpine application itself, and just called it like so:</p><pre><code class="language-html">Credits: <span x-text="numberFormat(credits)"></span><br/></code></pre><h2 id="wrap-up" tabindex="-1">Wrap Up <a class="header-anchor" href="#wrap-up">#</a></h2><p>All in all, it took me maybe an hour to make the change, so it wasn't too bad, and speaks to how easy it would be for a Vue developer to pick up and learn Alpine if they wanted, or needed to. Feel free to check out the current code here, <a href="https://github.com/cfjedimaster/IdleFleet">https://github.com/cfjedimaster/IdleFleet</a>, and if you want to waste, I mean enjoy, a few hours, check out the game here: <a href="https://idlefleet.netlify.app/">https://idlefleet.netlify.app/</a>. I've got more updates coming this week!</p>
Raymond Camdenraymondcamden@gmail.comhttps://www.raymondcamden.com/2024/03/01/til-submit-versus-requestsubmitTIL - submit() versus requestSubmit()2024-03-01T18:00:00+00:00
<p>Today I learned (well, technically, a few days ago, the week has been a <em>lot</em>), that the web platform supports a <a href="https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/requestSubmit">requestSubmit</a> method. Since the beginning of time (or the beginning of JavaScript), we've been able to submit forms like so:</p><pre><code class="language-js">var myForm = document.getElementById('theform');myForm.submit();</code></pre><p>I intentionally used <code>getElementById</code> there as a reminder of what we had before jQuery. Given that, why do we need <em>another</em> method? Two important requests.</p><h2 id="reason-the-first" tabindex="-1">Reason the First <a class="header-anchor" href="#reason-the-first">#</a></h2><p>When using <code>submit</code>, any validation of form fields is completely skipped. Consider this form:</p><pre><code class="language-html"><form method="post" id="theForm"> <p> <label for="name">Name</label> <input id="name" name="name" required> </p> <p> <label for="email">Email</label> <input id="email" name="email" type="email" required> </p> <p> <input type="submit"> </p></form></code></pre><p>I've got two fields, both required, with the second field using type <code>email</code>. If you hit submit, the form will stop itself from POSTing and show errors, but if you submit with JavaScript, that validation is completely ignored.</p><p>I added two more buttons to my HTML:</p><pre><code class="language-html"><p> <button id="testSubmit">Test submit()</button> <button id="testRequestSubmit">Test requestSubmit()</button></p></code></pre><p>And wrote some quick JavaScript to demo this:</p><pre><code class="language-js">const $form = document.querySelector('#theForm');document.querySelector('#testSubmit').addEventListener('click', () => { $form.submit() });document.querySelector('#testRequestSubmit').addEventListener('click', () => { $form.requestSubmit() });</code></pre><p>Clicking the first button immediately shows that validation is ignored. Clicking either the main submit button in the form or the tester button shows validation working.</p><p><img src="https://static.raymondcamden.com/images/2024/03/form1.jpg" alt="Screenshot showing validation being run on the first formfield" class="imgborder imgcenter" loading="lazy"></p><h2 id="reason-the-second" tabindex="-1">Reason the Second <a class="header-anchor" href="#reason-the-second">#</a></h2><p>Not only is validation ignored with <code>submit()</code>, any submit handler on the form itself is completely ignored. I added this:</p><pre><code class="language-js">$form.addEventListener('submit', e => { console.log('submit fired on form'); e.preventDefault();});</code></pre><p>And again, <code>submit()</code> ignores it and <code>requestSubmit()</code> runs it fine. I'm <em>mostly</em> sure I remember this aspect of <code>submit()</code>, but it's definitely been a while since I've thought about it.</p><p>Anyway, everyone loves the web platform. (Except Apple.) Here's a CodePen showing this in action if you want to see for yourself. (Which is 100% why most of my blog posts exist.)</p><p class="codepen" data-height="500" data-theme-id="dark" data-default-tab="html,result" data-slug-hash="wvZwNxR" data-editable="true" data-user="cfjedimaster" style="height: 500px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;"> <span>See the Pen <a href="https://codepen.io/cfjedimaster/pen/wvZwNxR"> requestSubmit test</a> by Raymond Camden (<a href="https://codepen.io/cfjedimaster">@cfjedimaster</a>) on <a href="https://codepen.io">CodePen</a>.</span></p><script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script><p>
Raymond Camdenraymondcamden@gmail.comhttps://www.raymondcamden.com/2024/02/26/testing-multiple-variations-of-generative-ai-promptsTesting Multiple Variations of Generative AI Prompts2024-02-26T18:00:00+00:00
<p>In nearly all of my recent explorations of Generative AI, I've come to realize how important prompts are. That hasn't necessarily translated me into writing better prompts all the time, but being aware of the problem is at least one step towards correcting it, right? One of the things that I thought would help me personally would be to have a tool to quickly compare and contrast different prompts. Obviously, I could just open up multiple tabs in a browser, but I really wanted something more direct and simplified for my purposes. With that in mind, let me share what I've built.</p><h2 id="prompt-tester" tabindex="-1">Prompt Tester <a class="header-anchor" href="#prompt-tester">#</a></h2><p>Prompt Tester is my incredibly imaginatively named tool to... um... test prompts. It begins with one prompt:</p><p><img src="https://static.raymondcamden.com/images/2024/02/pt1.jpg" alt="Prompt Tester web app with one empty prompt" class="imgborder imgcenter" loading="lazy"></p><p>The idea is that you enter your initial prompt, and then click one or more times to add more prompts. When ready, you can then just click "Generate Results":</p><p><img src="https://static.raymondcamden.com/images/2024/02/pt2.jpg" alt="Prompt Tester web app with two prompts and results" class="imgborder imgcenter" loading="lazy"></p><p>Before showing some example results, let me talk a bit about how it was built.</p><h2 id="the-code" tabindex="-1">The Code <a class="header-anchor" href="#the-code">#</a></h2><p>The backend is an <em>incredibly</em> simple Node.js server that handles throwing up the static HTML and responding to the front end API requests. Once again, I thank Todd Sharp for sharing his initial code with me. Here's the boring simple handling portion:</p><pre><code class="language-js">async function handler(req, res) { console.log('Entered handler.', req.method, req.url); if(req.method === 'GET' && req.url.indexOf('favicon.ico') === -1) { res.writeHead(200, { 'Content-Type':'text/html' }); res.write(fs.readFileSync('./demo.html')); res.end(); } else if(req.method === 'POST' && req.url === '/api') { let body = ''; req.on('data', chunk => { body += chunk.toString(); }); req.on('end', async () => { body = JSON.parse(body); console.log('BODY:\n', JSON.stringify(body, null, '\t')); let result = await callGemini(body.prompt); res.writeHead(200, { 'Content-Type':'application/json' }); res.write(JSON.stringify(result)); res.end(); }); }}const server = http.createServer(handler);server.listen(3000);console.log('Listening on port 3000');</code></pre><p>And the <code>callGemini</code> part, which just passes the prompt off to Google's <a href="https://ai.google.dev/">Gemini API</a>. In theory, this could be changed to ChatGPT or any other service.</p><pre><code class="language-js">const MODEL_NAME = "gemini-pro";const API_KEY = process.env.GOOGLE_AI_KEY;const genAI = new GoogleGenerativeAI(API_KEY);const model = genAI.getGenerativeModel({ model: MODEL_NAME });async function callGemini(text) { const generationConfig = { temperature: 0.9, topK: 1, topP: 1, maxOutputTokens: 2048, }; const safetySettings = [ { category: HarmCategory.HARM_CATEGORY_HARASSMENT, threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE, }, { category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE, }, { category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE, }, { category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE, }, ]; const parts = [ {text}, ]; const result = await model.generateContent({ contents: [{ role: "user", parts }], generationConfig, safetySettings, }); try { if(result.response.promptFeedback && result.response.promptFeedback.blockReason) { return { error: `Blocked for ${result.response.promptFeedback.blockReason}` }; } const response = result.response.candidates[0].content.parts[0].text; return { response }; } catch(e) { // better handling return { error:e.message } } }</code></pre><p>I'm proud to say that I'm now, finally, doing some basic error handling and trying to expose to the user (me) why a prompt was blocked.</p><p>The front end is relatively simple. I used <a href="https://milligram.io/">Milligram</a> for the core CSS (again, thank you Todd) and some vanilla JavaScript to handle adding new prompts and making the calls. Here's the HTML (minus the inline CSS):</p><pre><code class="language-html"><h1>Prompt Tester</h1><p>This tool helps you see the result of multiple prompts at the same time. The idea is to use similar prompts with different phrasing to see how it impacts the result.</p><div id="prompts"></div><template id="promptRow"><div class="row"> <div class="column column-33"><textarea placeholder="Enter your prompt."></textarea></div> <div class="column column-67 result"></div></div><hr></template><div class="float-right"><button id="addPrompt">Add Prompt</button> <button id="generateResults">Generate Results</button></div></code></pre><p>And then the JavaScript. Notice I used <code><template></code> above to handle the prompt layout. This is the first time I've used that tag and it was relatively easy.</p><pre><code class="language-js">let $template, $prompts, $addPromptsBtn, $generateResultsBtn;let promptCount = 0;document.addEventListener('DOMContentLoaded', init, false);async function init() { $addPromptsBtn = document.querySelector('#addPrompt'); $addPromptsBtn.addEventListener('click', addPrompt, false); $generateResultsBtn = document.querySelector('#generateResults') $generateResultsBtn.addEventListener('click', generateResults, false); $template = document.querySelector('#promptRow'); $prompts = document.querySelector('#prompts'); addPrompt();}function addPrompt() { promptCount++; const newPrompt = $template.content.cloneNode(true); $prompts.append(newPrompt);}async function generateResults() { // disable add prompt and generate $addPromptsBtn.setAttribute('disabled', 'disabled'); $generateResultsBtn.setAttribute('disabled', 'disabled'); let $results = document.querySelectorAll('div.result'); // clear any previous results $results.forEach(r => r.innerHTML = ''); /* gather prompts from the inputs */ let prompts = []; let $fields = document.querySelectorAll('#prompts textarea'); for(f of $fields) prompts.push(f.value); let promises = []; for(p of prompts) promises.push(getResult(p)); let results = await Promise.all(promises); // assign results to result area for each, handle errors - later // there's a one to one correspondance between our array of results and the result div for(let i=0;i<results.length;i++) { if(results[i].response) $results[i].innerHTML = marked.parse(results[i].response); else if(results[i].error) $results[i].innerHTML = `<strong>Error with Prompt: </strong> ${results[i].error}`; } $addPromptsBtn.removeAttribute('disabled'); $generateResultsBtn.removeAttribute('disabled');}async function getResult(p) { console.log('get result for',p); // quick return on empty prompt if(p.trim() === '') return { response:'<strong>Enter a prompt.</strong>' }; let req = await fetch('/api', { method:'POST', body: JSON.stringify({prompt:p.trim()})}); let result = await req.json(); return result;}</code></pre><p>I <em>think</em> that's all pretty simple DOM manipulation and API calling, but definitely let me know in a comment below if something doesn't make sense. Notice I fire off all my API calls at once and <code>Promise.all</code> wait for them to finish.</p><p>Also, note I use the <code>marked</code> library. The results from Google typically have Markdown and this helps render them nicely in my little web app.</p><p>All of this isn't terribly interesting I think, but let's look at some testing. As I said, I <em>knew</em> the style of prompt was important. Having it presented directly to me in a tool went a <em>heck</em> of a long way to reinforcing that.</p><h2 id="sample-result---gravity" tabindex="-1">Sample Result - Gravity <a class="header-anchor" href="#sample-result---gravity">#</a></h2><p>Let's start with a simple idea - explaining gravity, but for different age groups. Because of the large size of the results, I'm going to use a bit of CSS here to help keep things sane.</p><div style="background-color: #c0c0c0; padding: 10px; margin-bottom: 10px"><p><strong>Prompt: explain gravity to me but make it appropriate for a child in 1st grade</strong></p><p>Gravity is a superpower that our planet has! It's like an invisible force that makes everything on Earth stay on the ground.</p><p>Imagine you have a ball in your hand. When you drop it, what happens? It falls down, right? That's because of gravity. Gravity pulls the ball towards the center of the Earth, and so it falls to the ground.</p><p>Gravity is also what keeps us on the ground. It's what keeps our feet planted firmly on the Earth and prevents us from floating away!</p><p>Gravity is a very important force. It keeps everything in place and helps things work the way they do. Without gravity, we would all be floating around in space, and things would be very chaotic!</p><p>Here's a fun way to think about gravity: imagine that the Earth is a giant magnet, and everything on Earth is made of metal. The magnet pulls the metal objects towards it, and that's what keeps them on the ground.</p><p>Gravity is a very important and interesting force. It's one of the things that makes our planet such a unique and special place to live!</p></div><div style="background-color: #c0c0c0; padding: 10px; margin-bottom: 10px"><p><strong>Prompt: explain gravity to me but make it appropriate for a child in 7th grade</strong></p><p>Imagine you're playing with a ball. You toss it up in the air, and it comes back down to you. This is because of gravity. Gravity is a force that pulls things toward each other. It's what keeps you on the ground and what makes things fall down.</p><p>The amount of gravity between two objects depends on their mass. The more mass an object has, the stronger its gravity is. That's why things like planets and stars have more gravity than things like people and cars.</p><p>Gravity is also what keeps the planets in orbit around the sun. The sun's gravity is much stronger than the Earth's, so it pulls the Earth towards it. This keeps the Earth moving in a circle around the sun.</p><p>Gravity is a very important force in our universe. It keeps things in place and makes things move. Without gravity, the planets would fly off into space, and we would all float away.</p><p>Here are some fun facts about gravity:</p><ul><li>Gravity is responsible for the tides. The moon's gravity pulls on Earth's oceans, causing them to rise and fall.</li><li>Gravity is the reason why we have an atmosphere. Without gravity, the air would just float away into space.</li><li>Gravity is what makes it possible for us to walk. When we walk, our feet push against the ground, and the ground pushes back. This is what propels us forward.</li><li>Gravity is the reason why we don't float away into space. Earth's gravity pulls us towards its center, keeping us on the ground.</li></ul></div><p>Note the typo in the prompt below. Gemini handled it just fine.</p><div style="background-color: #c0c0c0; padding: 10px; margin-bottom: 10px"><p><strong>Prompt: explain gravity to me but make it appropriate for a college-aged studen</strong></p><p><strong>Gravity: The Force of Attraction</strong></p><p>Gravity is a fundamental force in the universe responsible for keeping celestial bodies, such as planets, moons, and stars, in motion. It is also what keeps us firmly planted to the ground and allows objects to fall when dropped.</p><p><strong>Newton's Law of Universal Gravitation:</strong></p><p>The law of universal gravitation states that every object in the universe attracts every other object with a force proportional to the product of their masses and inversely proportional to the square of the distance between them. Mathematically, it can be expressed as:</p><p>F = Gm1m2/r^2</p><p>Where:</p><p>F is the gravitational force between two objects. G is the gravitational constant (6.674 × 10^-11 N m^2/kg^2). m1 and m2 are the masses of the two objects. r is the distance between the centers of the two objects.</p><p><strong>Significance of Gravity:</strong></p><ol><li><p><strong>Keeps Celestial Bodies in Orbit:</strong>The gravitational pull of larger celestial bodies keeps smaller ones revolving around them. For example, Earth's gravity keeps the moon in orbit, while Sun's gravity keeps Earth and other planets in their respective orbits.</p></li><li><p><strong>Responsible for Tides:</strong>Gravity between the Earth and the moon causes ocean tides. As the moon orbits Earth, its gravitational pull creates a bulge in the ocean's water on the side facing the moon. Another bulge forms on the opposite side of Earth due to the inertial forces. As the Earth rotates, these bulges move across the planet's surface, resulting in high and low tides.</p></li><li><p><strong>Keeps Objects on Earth:</strong>Gravity pulls objects towards Earth's center, preventing them from floating away. This phenomenon allows us to walk, stand, and interact with our environment.</p></li><li><p><strong>Formation of Stars and Galaxies:</strong>Gravity plays a crucial role in the formation of stars and galaxies. The gravitational collapse of massive clouds of gas and dust leads to the creation of new stars. Similarly, the gravitational pull of galaxies keeps them bound together, preventing them from dispersing.</p></li><li><p><strong>Relativity Theory:</strong>Albert Einstein's general relativity theory provides a more comprehensive understanding of gravity. It explains that gravity is not a force but rather a curvature of spacetime caused by the presence of mass or energy. This curvature affects the motion of objects, causing them to follow curved paths rather than straight lines.</p></li></ol><p>In conclusion, gravity is a fundamental force responsible for keeping celestial bodies in motion, tides, and the behavior of objects on Earth. It is a complex phenomenon that has been studied extensively over the centuries, and it continues to fascinate scientists and researchers to this day.</p></div><h2 id="sample-result---pets" tabindex="-1">Sample Result - Pets <a class="header-anchor" href="#sample-result---pets">#</a></h2><p>For my second test, I started with a basic prompt and then a better version that asked for specific types of details:</p><div style="background-color: #c0c0c0; padding: 10px; margin-bottom: 10px"><p><strong>Prompt: why are cats so much better than dogs?</strong></p><p>Cats and dogs are both popular pets, and there is no definitive answer to the question of which one is better. Both cats and dogs have their own unique set of advantages and disadvantages, and the best pet for you will depend on your individual needs and preferences.</p><p>Some people prefer cats because they are generally more independent than dogs. Cats are typically happy to entertain themselves, and they don't need to be walked or taken outside for exercise. This can be a big advantage for people who live in apartments or who have busy schedules.</p><p>Cats are also known for being clean and tidy animals. They groom themselves regularly, and they typically use the litter box without any problems. This can be a big advantage for people who don't want to deal with the mess and smell of a dog.</p><p>Of course, there are also some disadvantages to having a cat. Cats can be more expensive than dogs, and they can be more difficult to train. Cats are also more likely to scratch furniture and other household items.</p><p>Ultimately, the decision of whether to get a cat or a dog is a personal one. There is no right or wrong answer, and the best pet for you will depend on your individual needs and preferences.</p><p>Here is a table summarizing the key differences between cats and dogs:</p><table><thead><tr><th>Characteristic</th><th>Cat</th><th>Dog</th></tr></thead><tbody><tr><td>Independence</td><td>More independent</td><td>Less independent</td></tr><tr><td>Exercise needs</td><td>Does not need to be walked or taken outside</td><td>Needs to be walked or taken outside</td></tr><tr><td>Cleanliness</td><td>Cleans itself regularly</td><td>Needs to be bathed regularly</td></tr><tr><td>Training</td><td>Can be difficult to train</td><td>Easier to train</td></tr><tr><td>Scratching</td><td>May scratch furniture and other household items</td><td>Does not typically scratch furniture</td></tr><tr><td>Cost</td><td>More expensive</td><td>Less expensive</td></tr></tbody></table></div><div style="background-color: #c0c0c0; padding: 10px; margin-bottom: 10px"><p><strong>Prompt: why are cats so much better than dogs? explain with a bulleted list of reasons that cover emotional well being and financial aspects</strong><p><strong>Emotional well-being:</strong></p><ul><li>Independence: Cats are more independent than dogs, which can be a good thing for people who want a pet that doesn't require a lot of attention.</li><li>Low maintenance: Cats are generally lower maintenance than dogs. They don't need to be walked or groomed as often, and they can be left alone for longer periods of time.</li><li>Quiet: Cats are generally quieter than dogs, which can be ideal for people who live in apartments or have noise-sensitive neighbors.</li><li>Therapeutic: Studies have shown that cats can have a calming effect on humans, and they can help to reduce stress and anxiety.</li></ul><p><strong>Financial aspects:</strong></p><ul><li>Lower cost: Cats are generally less expensive to own than dogs. They eat less food, they need fewer vet visits, and they don't require as much grooming.</li><li>Easier to travel with: Cats are easier to travel with than dogs. They can be transported in a carrier, and they don't need to be walked or exercised while you're away.</li><li>Longer lifespan: Cats have a longer lifespan than dogs, on average. This means that you can enjoy your cat's companionship for many years to come.</li></ul></div><h2 id="sample-result---chain-of-thought-prompting" tabindex="-1">Sample Result - Chain of Thought Prompting <a class="header-anchor" href="#sample-result---chain-of-thought-prompting">#</a></h2><p>The following test came from an <em>excellent</em> presentation from AWS re:Invent: <a href="https://www.youtube.com/watch?v=jlqgGkh1wzY">AWS re:Invent 2023 - Prompt engineering best practices for LLMs on Amazon Bedrock (AIM377)</a>. I found a lot of great advice from this session, including a good overview of Chain of Thought prompting, where the idea is you provide information about the steps used to solve a problem to help the GenAI engine better solve a prompt.</p><p>Now, the example given in that presentation went like so:</p><pre><code>Q: Roger has 5 tennis balls. he buys 2 more cans of tennis balls. Each can has 3 tennis balls. How many tennis balls does he have now?A: The answer is 11.Q. The cafeteria had 23 apples. If they used 20 to make lunch and bought 6 more, how many apples do they have?</code></pre><p>In the presentation, the model in use incorrectly returned 27. In my test, it worked correctly, but I absolutely saw issues with math back when I built my <a href="https://www.raymondcamden.com/2023/11/09/can-genai-help-you-win-in-vegas">generative AI blackjack advice demo</a> last November.</p><p>As I said, Gemini handled it fine: <code>A: The answer is 9.</code> But in Prompt Tester, I tried the suggested CoT prompt:</p><pre><code>Q: Roger has 5 tennis balls. he buys 2 more cans of tennis balls. Each can has 3 tennis balls. How many tennis balls does he have now?A: Roger started with 5 balls. 2 cans of 3 tennis balls each is 6 tennis balls. 5 + 6 = 11. The answer is 11.Q. The cafeteria had 23 apples. If they used 20 to make lunch and bought 6 more, how many apples do they have?</code></pre><p>And the result was improved, showing how it got to the answer:</p><pre><code>A: The cafeteria had 23 apples. They used 20 apples for lunch, which means they have 3 apples left. They then bought 6 more apples, so they now have a total of 3 + 6 = 9 apples.The answer is 9.</code></pre><p>The difference is pretty significant.</p><h3 id="the-code-1" tabindex="-1">The Code <a class="header-anchor" href="#the-code-1">#</a></h3><p>If this seems useful to you at all, feel free to grab the code from here: <a href="https://github.com/cfjedimaster/ai-testingzone/tree/main/prompt_tester">https://github.com/cfjedimaster/ai-testingzone/tree/main/prompt_tester</a>. As I mentioned, it shouldn't be that difficult to add <code>callChatGPT</code> or some such and use a different service. If you do, let me know!</p>
Raymond Camdenraymondcamden@gmail.comhttps://www.raymondcamden.com/2024/02/25/links-for-youLinks For You2024-02-25T18:00:00+00:00
<p>Good morning programs. Before I get started with the three links I want to share with yall, some personal notes.</p><ul><li>Tomorrow I'm giving a free online presentation at the <a href="https://adobe-coldfusion-online-summit-2024.attendease.com/">Adobe ColdFusion Online Summit</a> where I'll be talking about how to use Adobe Acrobat PDF Services with ColdFusion. Join me and see how easy the APIs are,or, join me and laugh at how bad my ColdFusion skills have gotten.</li><li>I had my second <code><Code><Br></code> episode last week and it went great (imo). I walked through building a simple to play web-based game based on Paper, Rocks, Scissors. You can watch the recording here: <a href="https://youtu.be/15SgRdJPdoE">https://youtu.be/15SgRdJPdoE</a></li><li>I decided to give another ad network a try, <a href="https://ethicalads.io">ethicalads.io</a>. I saw it on another site and loved how lightweight and simple they were. I'm not sure how well it will work, but if it offsets my only cost (the newsletter), then it's a win. As a reminder, I'm looking for sponsors for this blog, and if you or your company would like to do so, just reach out!</li></ul>
Raymond Camdenraymondcamden@gmail.comhttps://www.raymondcamden.com/2024/02/22/adding-a-guestbook-to-your-jamstack-site-yes-seriouslyAdding a Guestbook to Your Jamstack Site (Yes, Seriously)2024-02-22T18:00:00+00:00
<p>Don't do this. I'm serious. Or do it, I certainly don't listen to reason when it comes to building demos. I've been in web development for a very, <em>very</em> long time, and I've seen many trends come and go. Guestbooks were a way for folks to leave a comment on your site as a whole. I haven't seen one in ages, but some still linger. In fact, Ana Rodrigues has an absolutely <a href="https://ohhelloana.blog/guestbook/">lovely guestbook</a> driven by <a href="https://indieweb.org/Webmention">Webmentions</a>. And if you <em>really</em> want to, you can still download a Perl CGI <a href="https://www.scriptarchive.com/guestbook.html">guestbook</a> over at Matt's Script Archive. I haven't written Perl in decades, but I absolutely loved it back in the 90s. That being said, I had a free hour yesterday, was bored, and decided, why not do something fun? The result - my new <a href="/guestbook">guestbook</a> that you can visit today. Here's how you too can (but don't) add a guestbook to your Jamstack site. (My example is in Eleventy, but uses nothing specific to Eleventy.)</p><h2 id="the-database" tabindex="-1">The Database <a class="header-anchor" href="#the-database">#</a></h2><p>For my data, I decided to store information in a Google Sheet. That's a pretty lame database, but it worked easily enough. I set up a Google Sheet with four columns: Name, Comment, Date, and Approved. Name and Comment should be self-evident, but Date is a 'time since epoch' numerical value and Approved is <code>TRUE</code> or <code>FALSE</code>.</p><h2 id="getting-guestbook-entries" tabindex="-1">Getting Guestbook Entries <a class="header-anchor" href="#getting-guestbook-entries">#</a></h2><p>To retrieve guestbook entries, I built a <a href="https://pipedream.com">Pipedream</a> workflow with the following steps:</p><ul><li>The trigger is an HTTP trigger so I can call it via JavaScript. I'll be showing that later.</li><li>The next step is a built-in Google Sheet action to read data where I specified my spreadsheet, the sheet name, and a range. In my code, <code>A2:D10000</code>. If my guestbook gets over 10k entries, it's time to move to a real database.</li><li>Next, I wrote a code function to do two things - filter out unapproved entries and map the result to a more readable format. By default, the result of getting my data in the previous step is a 2D array. Mapping the result makes it easier to use:</li></ul><pre><code class="language-js">export default defineComponent({ async run({ steps, $ }) { return steps.get_values_in_range.$return_value.filter(a => a[3] === 'TRUE').map(a => { return { name: a[0], comment: a[1], date: a[2] } }); },})</code></pre><ul><li>The final step just returns the data:</li></ul><pre><code class="language-js">export default defineComponent({ async run({ steps, $ }) { await $.respond({ status: 200, headers: { "Content-Type":"application/json"}, body: JSON.stringify(steps.filter_and_map.$return_value), }) },})</code></pre><p>You can see the result of this yourself here: <a href="https://eoxzk4xd3lr6trv.m.pipedream.net/">https://eoxzk4xd3lr6trv.m.pipedream.net/</a>.</p><h2 id="adding-guestbook-entries" tabindex="-1">Adding Guestbook Entries <a class="header-anchor" href="#adding-guestbook-entries">#</a></h2><p>To add a guestbook entry, I created another Pipedream workflow. It's also HTTP triggered of course, and does the following:</p><ul><li><p><strong>Edited at 1:47PM</strong> I just added a new step to validate that the name and comment field was sent. Duh, I should have done that initially. If they are not passed, the workflow ends.</p></li><li><p>First, it uses another built-in Pipedream action that adds rows to Google Sheets. I look for the name and comment value in the body of the HTTP trigger, set Date automatically, and Approved to false. For the most part, this just worked, but check out what I do with Name and Comment:</p></li></ul><p><img src="https://static.raymondcamden.com/images/2024/02/gb1.jpg" alt="Settings for Pipedream action" class="imgborder imgcenter" loading="lazy"></p><p>For both of the user-submitted content, I strip out any and all HTML. This is a safety measure to ensure nothing naughty gets in. I've already set it up such that it's set to not being approved by default, but this extra step ensures I don't have to manually clean input.</p><ul><li>Next for the fun part. I need to know when someone adds an entry, so to do that, I'm going to send me email. The email will include the name and comment, and a method to approve the entry. I'll share the code then explain more:</li></ul><pre><code class="language-js">export default defineComponent({ async run({ steps, $ }) { let html = `<h2>Guestbook Entry Submission</h2><p>The following information was submitted:</p><p>Name: <b>${steps.trigger.event.body.name.replace(/<.*?>/g,'')}</b></p><p>Comments:<br/><b>${steps.trigger.event.body.comment.replace(/<.*?>/g,'')}</b></p><p><a href="${process.env.APPROVE_GB}/?range=${encodeURIComponent(steps.add_single_row.$return_value.updatedRange)}">Click to Approve</a></p> `; return html; },})</code></pre><p>The beginning just outputs the simple values. For approval, I'm referencing the third and final workflow I'll show next. I used an environment variable for that because I want to keep the URL secret, and my Pipedream workflow is tied to a public GitHub repository so I can share stuff. Obviously, that was my choice and I could have used a private repo. To handle knowing <em>what</em> to approve, I used the result of the previous step that added the data. The <code>updatedRange</code> value looks like so: <code>Sheet1!A9:D9</code>.</p><ul><li>The final step just uses the built-in Pipedream step to email me. Here's an example:</li></ul><p><img src="https://static.raymondcamden.com/images/2024/02/gb2.jpg" alt="Sample email sent to allow Guestbook entry approval" class="imgborder imgcenter" loading="lazy"></p><h2 id="approving-guestbook-entries" tabindex="-1">Approving Guestbook Entries <a class="header-anchor" href="#approving-guestbook-entries">#</a></h2><p>The third and final Pipedream workflow is another HTTP-driven workflow with just two steps (ignoring the trigger):</p><ul><li>The first step uses another built-in Pipedream action to update Google Sheets data. For the Cell, I specify this: <code>{{steps.trigger.event.query.range.split(':').pop()}}</code>. If you remember, I passed the updated cell range via query string in the email, so if I split on the colon, I get the final cell (the Approved column) and can then set it to true.</li></ul><p><img src="https://static.raymondcamden.com/images/2024/02/gb3.jpg" alt="Screenshot of PD action" class="imgborder imgcenter" loading="lazy"></p><ul><li>The final step simply redirects me (as I'll be the one clicking) to the guestbook:</li></ul><pre><code class="language-js">export default defineComponent({ async run({ steps, $ }) { await $.respond({ status: 302, headers: { 'Location':'https://www.raymondcamden.com/guestbook' }, }) },})</code></pre><h2 id="the-guestbook" tabindex="-1">The Guestbook <a class="header-anchor" href="#the-guestbook">#</a></h2><p>Ok, so far I've shown the serverless functions built on Pipedream to support the workflow. Now let's look at how it's rendered. You can visit the <a href="/guestbook">guestbook</a> now, but if you'd rather not open a tab, here it is in all its glory:</p><p><img src="https://static.raymondcamden.com/images/2024/02/gb4.jpg" alt="Screenshot of the guestbook." class="imgborder imgcenter" loading="lazy"></p><p>Given that we're building on the Jamstack, we've got multiple different ways to build this.</p><ol><li>One would be entirely static. In Eleventy, I could use a <code>data</code> file to fetch the entries and just render them at build time. As I'm the one who approves entries, I could manually kick off a build when I do, or even update my Pipedream workflow to call Netlify and request a build. For adding entries, I'd either use JavaScript, or a serverless function on Netlify.</li><li>I could also just use JavaScript to fetch entries and handle the form submission.</li><li>I could also get fancy and use a combination. At build, fetch all entries, and note the timestamp of the last one. I could then update my "Get Guestbook Entries" workflow to allow for an optional timestamp such that my code could fetch entries created after build time.</li></ol><p>I went with option two for simplicity's sake. Here's the HTML I used:</p><pre><code class="language-html"><div id="entries"> <i>Loading guestbook entries, please stand by. It will be worth the wait, honest.</i></div><h3>Add Your Entry</h3><form id="addEntry"><p><label for="name">Your Name:</label> <input id="name"></p><p><textarea id="comment" placeholder="Your comment"></textarea></p><p><input type="submit" value="Save Entry" id="submitButton"></p></form></code></pre><p>Fairly simple as the real work is done in JavaScript. Here's the entirety of that:</p><pre><code class="language-js">const GB_URL = 'https://eoxzk4xd3lr6trv.m.pipedream.net';const ADD_GB = 'https://eo8ymuvqefph1ce.m.pipedream.net';const formatter = new Intl.DateTimeFormat('en-us', { dateStyle:'full', timeStyle:'short'});let $name, $comment, $button, origTextButtonText;document.addEventListener('DOMContentLoaded', init, false);async function init() { console.log('fetching gb entries'); let entryReq = await fetch(GB_URL); let entries = await entryReq.json(); let s = ''; entries.forEach(e => { s += `<div class="entry"><b>${e.name}</b> said on ${formatter.format(e.date)}:<br/><i>${e.comment}</i></div> `; }); document.querySelector('#entries').innerHTML = s; console.log(entries); $name = document.querySelector('#name'); $comment = document.querySelector('#comment'); $button = document.querySelector('#submitButton'); origTextButtonText = $button.value; document.querySelector('form#addEntry').addEventListener('submit', addEntry, false);}async function addEntry(e) { e.preventDefault(); let name = $name.value.trim(); let comment = $comment.value.trim(); if(name === '' || comment === '') return; console.log(name, comment); $button.setAttribute('disabled', 'disabled'); $button.value = 'Submitting...'; let resp = await fetch(ADD_GB, { method: 'POST', body: JSON.stringify({ name, comment }) }); // currently we assume 204 and don't handle errors, maybe later... $name.value = ''; $comment.value = ''; $button.removeAttribute('disabled'); $button.value = origTextButtonText; alert('Your guestbook entry is submitted for approval. Thank you!');}</code></pre><p>From the top, I initialize a few variables, and on document load, hit my endpoint to get entries. I render them and use the browser's <code>Intl</code> API to nicely render the dates and times. The form submission is also pretty vanilla - basically, just ensure the name and comment aren't blank, I don't even tell the user if they made a mistake.</p><p>If you want to do this yourself, you can find the code here:</p><ul><li><a href="https://github.com/cfjedimaster/General-Pipedream-Stuff/tree/production/get-guestbook-entries-p_D1Cza5O">Get Entries Workflow</a></li><li><a href="https://github.com/cfjedimaster/General-Pipedream-Stuff/tree/production/add-guestbook-entry-p_yKCwGWl">Add Guestbook Entry</a></li><li><a href="https://github.com/cfjedimaster/General-Pipedream-Stuff/tree/production/approve-guestbook-entry-p_MOCayaq">Approve Guestbook Entry</a></li><li><a href="https://github.com/cfjedimaster/raymondcamden2023/blob/main/src/misc/guestbook.liquid">Guestbook HTML/JS</a></li></ul><p>Come back next week when I'll show you how to add a page counter to your site!</p>
Raymond Camdenraymondcamden@gmail.comhttps://www.raymondcamden.com/2024/02/19/using-generative-ai-to-organize-video-game-screenshotsUsing Generative AI to Organize Video Game Screenshots2024-02-19T18:00:00+00:00
<p>Way back in January (remember January), I wrote a blog post describing how to use <a href="https://www.raymondcamden.com/2024/01/26/using-generative-ai-to-improve-image-filenames">gen ai to improve image filenames</a>. This worked by uploading the image to Google Gemini, asking for a short description, and using that description for a new filename. Recently I was thinking about that demo and was curious how well it would work for video games.</p><p>As always, I did a few quick tests in <a href="https://aistudio.google.com/">Google AI Studio</a>. I did some quick Googling for various games and screenshots, and the results were pretty impressive. Here are three mostly modern examples:</p><p><img src="https://static.raymondcamden.com/images/2024/02/game1.jpg" alt="Screenshot from AC Valhalla, correctly identified" class="imgborder imgcenter" loading="lazy"></p><p><img src="https://static.raymondcamden.com/images/2024/02/game2.jpg" alt="Screenshot from Diablo 4, correctly identified" class="imgborder imgcenter" loading="lazy"></p><p>And here's a first failure, identifying this as Final Fantasy 14, not 16.</p><p><img src="https://static.raymondcamden.com/images/2024/02/game3.jpg" alt="Screenshot from FF16, incorrectly identified" class="imgborder imgcenter" loading="lazy"></p><p>It did well for one <em>really</em> old game, although to be fair the name is in the picture:</p><p><img src="https://static.raymondcamden.com/images/2024/02/game4.jpg" alt="Screenshot from Zork 1, correctly identified" class="imgborder imgcenter" loading="lazy"></p><p>Although failed on this rather obscure one. It's Bard's Tale 2, not Betrayal at Krondor.</p><p><img src="https://static.raymondcamden.com/images/2024/02/game5.jpg" alt="Screenshot from Bard's Tale 2, incorrectly identified" class="imgborder imgcenter" loading="lazy"></p><p>Also, note it didn't follow my directions to answer with just the name of the game.</p><p>I then tried a few race games. I was really curious about this as with the high fidelity of modern racing games, it feels like it would be a difficult task. Surprisingly, it got them right:</p><p><img src="https://static.raymondcamden.com/images/2024/02/game6.jpg" alt="Screenshot from Forza Horizon 5, correctly identified" class="imgborder imgcenter" loading="lazy"></p><p><img src="https://static.raymondcamden.com/images/2024/02/game7.jpg" alt="Screenshot from Gran Turismo 4, correctly identified" class="imgborder imgcenter" loading="lazy"></p><p>I then went <em>super</em> old school and obscure and it failed, but it was worth a shot. (The first person to identify it <em>without</em> using reverse image search earns 200 Nerd Points.)</p><p><img src="https://static.raymondcamden.com/images/2024/02/game8.jpg" alt="Screenshot incorrectly identified" class="imgborder imgcenter" loading="lazy"></p><p>So all in all... it worked reasonably well. So let's talk automation!</p><h2 id="automating-the-process" tabindex="-1">Automating the Process <a class="header-anchor" href="#automating-the-process">#</a></h2><p>Nearly two years ago, I blogged about <a href="https://www.raymondcamden.com/2022/04/23/store-nintendo-switch-screenshots-in-the-cloud-using-pipedream">copying Nintendo Switch screenshots</a> to Dropbox. This made use of the fact that the Switch can post to Twitter and you can use <a href="https://pipedream.com">Pipedream</a> to scrape media from a Twitter account. As far as I know, that probably doesn't work since Twitter crapped the bed on developer access. While it <em>did</em> work, the images I got had random names for images.</p><p><img src="https://static.raymondcamden.com/images/2024/02/game9.jpg" alt="Screenshot from Dropbox showing 3 Switch screenshots, all with random filenames." class="imgborder imgcenter" loading="lazy"></p><p>The XBox can upload to OneDrive and actually does name the images, which is super helpful.</p><p><img src="https://static.raymondcamden.com/images/2024/02/game10.jpg" alt="Screenshot from OneDrive showing a Diablo 4 screenshot." class="imgborder imgcenter" loading="lazy"></p><p>In case you can't read it, the filename is <code>Diablo IV-2024_02_19-1514-24.png</code>. It's not only got the game in the screenshot, but the date and time as well. For the purposes of this blog post, I'm going to ignore that but in a real application, I'd probably just handle the XBox images with a bit of custom code, no AI needed.</p><p>Playstation is a bit more wonky and a bit surprising. As far as I can tell, you can share videos on YouTube, but screenshots can only be added to the PS app or copied to a USB drive. Again, surprising.</p><p>Here's a screenshot showing how the images are named, and as you can see, nothing relevant is included.</p><p><img src="https://static.raymondcamden.com/images/2024/02/game11.jpg" alt="Screenshot from my phone's local files, showing the PS screenshot with an unhelpful name." class="imgborder imgcenter" loading="lazy"></p><p>For the purpose of this, let's make some assumptions (nothing ever goes wrong with that, right?). We will assume that our screenshots all get stored in a Dropbox folder named, <code>SSIn</code>.</p><p>I'm going to build a Pipedream workflow that will:</p><ul><li>Trigger on a new file added to SSIn.</li><li>Download the image.</li><li>Use Gemini AI to determine the game.</li><li>Use the name as a new folder in Dropbox, under SSOut, So for example, <code>/SSOut/Galaga</code></li><li>Upload the image and auto rename it based on the time. This lets us know when the screenshot was - well not taken, but at least added to Dropbox.</li></ul><p>Let's break it down:</p><h2 id="step-one---the-trigger" tabindex="-1">Step One - The Trigger <a class="header-anchor" href="#step-one---the-trigger">#</a></h2><p>This part is relatively simple in Pipedream. I created a trigger based on the Dropbox action for new files. I specified the path and told it to include a link so it could be downloaded.</p><p><img src="https://static.raymondcamden.com/images/2024/02/game12.jpg" alt="Dropbox trigger" class="imgborder imgcenter" loading="lazy"></p><h2 id="step-two---download-the-file" tabindex="-1">Step Two - Download the File <a class="header-anchor" href="#step-two---download-the-file">#</a></h2><p>The next step is another built-in Pipedream action, downloading a file. In this case, it gets downloaded to <code>/tmp</code> with the original filename.</p><p><img src="https://static.raymondcamden.com/images/2024/02/game13.jpg" alt="Download action" class="imgborder imgcenter" loading="lazy"></p><h2 id="step-three---use-ai-to-determine-the-game" tabindex="-1">Step Three - Use AI to Determine the Game <a class="header-anchor" href="#step-three---use-ai-to-determine-the-game">#</a></h2><p>The next step is a Node.js step that essentially takes the code output from AI Studio and has it work with the file in <code>/tmp</code>:</p><pre><code class="language-js">import fs from 'fs';import { GoogleGenerativeAI, HarmCategory, HarmBlockThreshold,} from "@google/generative-ai";async function identifyPic(path, key) { const MODEL_NAME = "gemini-1.0-pro-vision-latest"; const API_KEY = key; const genAI = new GoogleGenerativeAI(API_KEY); const model = genAI.getGenerativeModel({ model: MODEL_NAME }); const generationConfig = { temperature: 0.4, topK: 32, topP: 1, maxOutputTokens: 4096, }; const safetySettings = [ { category: HarmCategory.HARM_CATEGORY_HARASSMENT, threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE, }, { category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE, }, { category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE, }, { category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE, }, ]; const parts = [ {text: "Can you identify what game this screenshot comes from? Just tell me the game.\n\n"}, { inlineData: { mimeType: "image/png", data: Buffer.from(fs.readFileSync(path)).toString("base64") } }, ]; const result = await model.generateContent({ contents: [{ role: "user", parts }], generationConfig, safetySettings, }); return result.response.candidates[0].content.parts[0].text;}export default defineComponent({ async run({ steps, $ }) { return await identifyPic(steps.download_file_to_tmp.$return_value[1], process.env.PALM_KEY); },})</code></pre><p>The only really important part here is the first argument to <code>identifyPic</code>, the path. Note that the path is the <em>second</em> result value from the download step. This definitely surprised me.</p><h2 id="step-four---create-a-folder" tabindex="-1">Step Four - Create a Folder <a class="header-anchor" href="#step-four---create-a-folder">#</a></h2><p>The next step was also a 'built-in', creating the folder. Now, I should warn you. I'm pretty sure this step is going to throw an error if run twice with the same game. The action's configuration lets you pick a new name if it already exists, but doesn't have a "don't make it if already exists" parameter. I didn't get a chance to test that, but, if it <em>is</em> an issue, I'd simply switch to the Pipedream feature that lets you hit any Dropbox API with the right credentials, check the Dropbox API docs, and see if it's possible there, or heck, just wrap in a <code>try/catch</code>.</p><p>Outside of that, note that I added a <code>trim()</code> to the result from Google as it had a space in front.</p><p><img src="https://static.raymondcamden.com/images/2024/02/game14.jpg" alt="Create folder" class="imgborder imgcenter" loading="lazy"></p><p>Also note that sometimes Gemini will return a sentence, not just a game name. For example, "The game is X". I figure that will be pretty obvious in the output and a human can handle that.</p><h2 id="step-five---upload" tabindex="-1">Step Five - Upload <a class="header-anchor" href="#step-five---upload">#</a></h2><p>The final step is to upload the picture to the new output directory. To get the name to be date based, I used a pretty long expression:</p><p><img src="https://static.raymondcamden.com/images/2024/02/game15.jpg" alt="Upload file" class="imgborder imgcenter" loading="lazy"></p><p>Did it work? Sure did! I mean it's brittle as heck, but here's the output from a few runs. First, top-level folder:</p><p><img src="https://static.raymondcamden.com/images/2024/02/game16.jpg" alt="Folders" class="imgborder imgcenter" loading="lazy"></p><p>And here's the contents of the Valhalla one:</p><p><img src="https://static.raymondcamden.com/images/2024/02/game17.jpg" alt="Valhalla file result" class="imgborder imgcenter" loading="lazy"></p><h2 id="the-code%2C-and-everything-else" tabindex="-1">The Code, and Everything Else <a class="header-anchor" href="#the-code%2C-and-everything-else">#</a></h2><p>All in all, I think this works "Ok" to "Well", but not perfect. I definitely think a human could help, and in fact, one thing Pipedream makes easy is sending emails. You could easily add an email notification at the end where a person could see if something got misfiled.</p><p>Anyway, this was fun, and if you want to use this yourself in Pipedream, you can find the workflow here: <a href="https://github.com/cfjedimaster/General-Pipedream-AI-Stuff/tree/production/identify-and-sort-screenshots-p_brCmQz3">https://github.com/cfjedimaster/General-Pipedream-AI-Stuff/tree/production/identify-and-sort-screenshots-p_brCmQz3</a></p>
Raymond Camdenraymondcamden@gmail.comhttps://www.raymondcamden.com/2024/02/16/google-gemini-as-your-dungeon-masterGoogle Gemini as Your Dungeon Master2024-02-16T18:00:00+00:00
<p>So this is absolutely just another example of me playing around too much, but I had to share. As I mentioned in my <a href="https://www.raymondcamden.com/2024/02/15/google-gemini-15-announced-but-not-yet-released">post</a> yesterday, Google's AI Studio now supports uploading files and working with them in your prompt. Today I decided to give the Chat interface a try as I hadn't yet played with it. On a whim, I googled for "dungeons and dragons rules PDF" and... well, you won't believe what happened next. (Sorry, I couldn't resist.)</p><p>First off, the most important thing to note if you want to test with PDFs, ensure that they are OCRed. Right now AI Studio does not handle that well, but it should be corrected in the future. My Google search turned up the PDF here, <a href="https://media.wizards.com/2018/dnd/downloads/DnD_BasicRules_2018.pdf">https://media.wizards.com/2018/dnd/downloads/DnD_BasicRules_2018.pdf</a>, and I confirmed it was properly OCRed with a very scientific test of trying to select a few words. Note that it's from 2018 and probably a bit out of date, but I figured it was good enough.</p><p>I uploaded the PDF to Gemini and ensured I was using Gemini 1.5. This is important as the size of the PDF (180 pages) means a rather large token size. The UI reports this nicely:</p><p><img src="https://static.raymondcamden.com/images/2024/02/dd1.jpg" alt="UI reporting 211,690 tokens" class="imgborder imgcenter" loading="lazy"></p><p>For my first test, I simply asked it "what are the attributes of a player character?" Here's what I got:</p><div style="background-color: #c0c0c0; padding: 10px">In Dungeons & Dragons, player characters (PCs) are defined by several key attributes:<ol><li><p>Race: This determines your character's physical characteristics, ancestry, and innate abilities. Examples include humans, elves, dwarves, halflings, dragonborn, tieflings, etc. Each race has unique traits that affect gameplay.</p></li><li><p>Class: This represents your character's chosen profession and determines their skills, abilities, and progression path. Examples include fighter, wizard, rogue, cleric, bard, etc. Each class has unique features and abilities that define their role in the party.</p></li><li><p>Ability Scores: These six scores represent your character's core physical and mental capabilities: Strength, Dexterity, Constitution, Intelligence, Wisdom, and Charisma. They influence almost every action your character takes, from attacking and defending to interacting with the world and casting spells.</p></li><li><p>Skills: These represent specific areas of expertise your character has developed through training or experience. Examples include Athletics, Acrobatics, Stealth, Perception, Persuasion, Arcana, etc. Skills are tied to ability scores and determine your character's success in various tasks.</p></li><li><p>Background: This details your character's history and past experiences before becoming an adventurer. Examples include soldier, criminal, noble, sage, etc. Backgrounds provide additional skills, proficiencies, and roleplaying hooks.</p></li><li><p>Equipment: This includes weapons, armor, tools, and other items your character carries. Equipment affects your character's capabilities in combat and exploration.</p></li><li><p>Personality and Alignment: These define your character's moral compass, beliefs, and behavior. Alignment is a combination of two factors: morality (good, evil, or neutral) and attitude towards society and order (lawful, chaotic, or neutral).</p></li><li><p>Backstory: This is the narrative of your character's life before the game begins, including their motivations, goals, and relationships. A well-developed backstory adds depth and helps you roleplay your character.</p></li></ol><p>These attributes work together to create a unique and well-rounded character that you can roleplay and guide through adventures in the D&D world.</p></div><p>I'm pretty rusty in D&D, but that surely seems right! (When I shared this in a chat related to Gemini, William Ito skimmed it said it looks correct for that era - so I'm taking that as another sign it worked well.)</p><p>I then asked, "what races are available to player characters?"</p><div style="background-color: #c0c0c0; padding: 10px">The most common player character races in Dungeons & Dragons are:<p>Humans: Adaptable and versatile, with no major strengths or weaknesses.<br/>Elves: Graceful and long-lived, with a natural affinity for magic and a resistance to charm effects. Subraces include High Elves, Wood Elves, and Dark Elves (Drow).<br/>Dwarves: Stout and hardy, with a resistance to magic and poison, and proficiency in combat and craftsmanship. Subraces include Hill Dwarves and Mountain Dwarves.<br/>Halflings: Nimble and small, with a natural ability to hide and a resistance to fear. Subraces include Lightfoot Halflings and Stout Halflings.<br/><p>The Player's Handbook also introduces several less common races, including:<p>Dragonborn: Proud and draconic humanoids with scales and the ability to breathe fire.<br/>Tieflings: Demonic descendants with a natural affinity for magic and resistance to fire.<br/>Gnomes: Inventive and curious small creatures with a natural ability to tinker and a resistance to magic.<br/>Half-elves: Combining human and elven traits, they are versatile and have a resistance to charm effects.<br/>Half-orcs: Combining human and orc traits, they are strong and fierce with a resistance to being knocked unconscious.<br/><p>Additionally, some campaign settings and sourcebooks introduce even more exotic races, such as Aasimar, Firbolg, Goliath, Aarakocra, Genasi, and many others. The availability of these races depends on your Dungeon Master and the specific campaign setting you are playing in.<p>Remember, the most important factor in choosing a race is to pick one that excites you and fits the character concept you have in mind.</div><p>I have absolutely no clue if those exotic races exist, but I did a quick search on Aasimar and it looks like it certainly does: <a href="https://forgottenrealms.fandom.com/wiki/Aasimar">Aasimar</a></p><p>Alright, now, at this point, I was impressed. But I thought - why not push my luck?</p><p>"Given the rules to create a character, create one with all the required characters and give it a name"</p><p>And holy crap - Gemini delivered!</p><div style="background-color: #c0c0c0; padding: 10px"><h3>Character Name: Lyra Swiftfoot</h3>Race: Lightfoot Halfling<br>Class: Rogue (Thief)<br>Background: Entertainer (Dancer)<br>Alignment: Chaotic Good<br><br>Ability Scores: (Using the standard set)<ul><li>Strength: 8 (-1)</li><li>Dexterity: 17 (+3)</li><li>Constitution: 14 (+2)</li><li>Intelligence: 13 (+1)</li><li>Wisdom: 12 (+1)</li><li>Charisma: 15 (+2)</li></ul><p>Hit Points: 10 (8 + 2 from Constitution modifier)</p><p>Proficiencies:</p><ul><li>Armor: Light armor</li><li>Weapons: Simple weapons, hand crossbows, longswords, rapiers, shortswords</li><li>Tools: Thieves' tools, Disguise kit (from background)</li><li>Saving Throws: Dexterity, Intelligence</li><li>Skills: Acrobatics (+5), Perception (+4), Performance (+6), Stealth (+7) (Expertise in Stealth and Performance)</li></ul><p>Equipment:</p><ul><li>Rapier</li><li>Shortbow and quiver of 20 arrows</li><li>Leather armor</li><li>Two daggers</li><li>Thieves' tools</li><li>Disguise kit</li><li>Backpack</li><li>Bedroll</li><li>Mess kit</li><li>Tinderbox</li><li>10 torches</li><li>10 days of rations</li><li>Waterskin</li><li>50 feet of hempen rope</li><li>Pouch with 15 gp</li></ul><p>Personality Traits:</p><ul><li>I have a quick wit and a fondness for pranks.</li><li>I am fiercely loyal to my friends and always look out for their safety.</li></ul><p>Ideal:</p><ul><li>Freedom: Everyone should be free to live their life as they see fit. (Chaotic)</li></ul><p>Bond:</p><ul><li>I will do anything to protect my younger sister, who is the only family I have left.</li></ul><p>Flaw:</p><ul><li>I have a weakness for shiny objects and can't resist the urge to steal them, even if it means putting myself at risk.</li></ul><p>Backstory: Lyra grew up on the streets, using her nimbleness and charm to survive. She learned to dance for coins and honed her skills in stealth and deception to pilfer from unsuspecting onlookers. After her sister was kidnapped by slavers, Lyra vowed to do whatever it takes to find and rescue her, even if it means venturing into the darkest corners of the world.</p><p>This is just a basic example, and you can further customize Lyra by adding details to her appearance, personality, and backstory. You can also choose different skills, proficiencies, or equipment based on your preferences and the campaign setting.</p></div><p>By the way, the formatting above is a bit off, but that came from me copying and pasting from Gemini and being a bit lazy on sprucing it up. I figured I'd push my luck and try again:</p><p>"do that again please"</p><div style="background-color: #c0c0c0; padding: 10px"><h3>Character Name: Falion Sunstrider</h3><p>Race: High Elf<br>Class: Wizard (Evocation)<br>Background: Sage (Researcher)<br>Alignment: Neutral Good<br><br><br>Ability Scores: (Using the standard set)</p><ul><li>Strength: 8 (-1)</li><li>Dexterity: 14 (+2)</li><li>Constitution: 13 (+1)</li><li>Intelligence: 17 (+3)</li><li>Wisdom: 12 (+1)</li><li>Charisma: 10 (+0)</li></ul><p>Hit Points: 8 (6 + 1 from Constitution modifier)</p><p>Proficiencies:</p><ul><li>Weapons: Daggers, darts, slings, quarterstaffs, light crossbows</li><li>Tools: None</li><li>Saving Throws: Intelligence, Wisdom</li><li>Skills: Arcana (+6), History (+6), Investigation (+4) (Expertise in Arcana and History)</li></ul><p>Equipment:</p><ul><li>Quarterstaff</li><li>Arcane focus (crystal)</li><li>Scholar's pack</li><li>Spellbook with six 1st-level wizard spells (Burning Hands, Charm Person, Detect Magic, Mage Armor, Magic Missile, Shield)</li><li>Backpack</li><li>Bedroll</li><li>Mess kit</li><li>Tinderbox</li><li>10 torches</li><li>10 days of rations</li><li>Waterskin</li><li>50 feet of hempen rope</li><li>Pouch with 10 gp</li></ul><p>Personality Traits:</p><ul><li>I am deeply curious about the world and always eager to learn new things.</li><li>I am absent-minded and often lost in my own thoughts.</li></ul><p>Ideal:</p><ul><li>Knowledge: The path to power and self-improvement is through knowledge. (Neutral)</li></ul><p>Bond:</p><ul><li>I am searching for the lost library of a legendary archmage, rumored to contain powerful spells and forgotten lore.</li></ul><p>Flaw:</p><ul><li>I am overconfident in my abilities and sometimes underestimate the dangers I face.</li></ul><p>Backstory: Falion spent his youth studying in a prestigious academy of magic, quickly surpassing his peers with his thirst for knowledge and natural talent for evocation magic. After hearing rumors of a lost library containing powerful spells and forgotten lore, Falion set out on a journey to find it, hoping to unlock its secrets and further his magical abilities.</p><p>This is just a basic example, and you can further customize Falion by adding details to his appearance, personality, and backstory. You can also choose different skills, proficiencies, or equipment based on your preferences and the campaign setting.</p></div><p>So at this point, I was blown away. Like, I don't even care if it broke any particular D&D rules, that was just fun. But... yeah, I pushed my luck <em>one</em> ... <em>last</em> ... <em>time</em> ...</p><p>"of the two characters you just made, who do you think would be a better party leader?"</p><div style="background-color: #c0c0c0; padding: 10px">Between Lyra Swiftfoot and Falion Sunstrider, Lyra would likely be the better party leader for several reasons:<ol><li><p><strong>Charisma</strong>: Lyra has a higher Charisma score, which is important for inspiring and motivating others. Her background as an entertainer also suggests she is comfortable in front of crowds and skilled at interacting with people.</p></li><li><p><strong>Skills</strong>: Lyra's skillset is more suited to leadership. Her expertise in Persuasion and Deception allows her to negotiate effectively and navigate social situations, while her proficiency in Perception and Stealth makes her a good scout and strategist.</p></li><li><p><strong>Personality</strong>: Lyra's personality traits suggest she is quick-witted and loyal, both valuable qualities in a leader. Her flaw, while potentially problematic, can be mitigated by her other strengths and the support of her party.</p></li><li><p><strong>Class</strong>: While wizards can be powerful, their focus on spellcasting and relative lack of social skills often makes them less effective leaders.</p></li></ol><p>Falion, on the other hand, excels in knowledge and magical power, making him a valuable asset to the party in terms of strategy and information gathering. However, his lower Charisma and lack of social skills might hinder his ability to effectively lead and inspire others. Additionally, his absent-mindedness and overconfidence could lead to poor decisions in critical situations.</p><p>Of course, the effectiveness of a party leader also depends on the specific campaign setting, the party composition, and the players' roleplaying preferences. In some situations, Falion's knowledge and magical prowess might be more valuable than Lyra's social skills. Ultimately, the best party leader is the one that the players and the Dungeon Master agree upon and who can effectively guide the party through their adventures.</p></div><p>My god. That's just... perfect. And honestly, it feels like the right choice for the right reasons.</p><p>Anyway, this isn't necessarily a deep look at this particular feature, but it was so fun I had to share!</p>
Raymond Camdenraymondcamden@gmail.com