<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Theo Slater</title>
    <description>The latest articles on DEV Community by Theo Slater (@theoslater).</description>
    <link>https://dev.to/theoslater</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3873857%2Fab0e0846-ac43-45ff-969e-bf672db0db09.jpeg</url>
      <title>DEV Community: Theo Slater</title>
      <link>https://dev.to/theoslater</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/theoslater"/>
    <language>en</language>
    <item>
      <title>Building PolyUI: a local-first AI chat client with Tauri and Rust</title>
      <dc:creator>Theo Slater</dc:creator>
      <pubDate>Mon, 06 Jul 2026 00:17:34 +0000</pubDate>
      <link>https://dev.to/theoslater/building-polyui-a-local-first-ai-chat-client-with-tauri-and-rust-40ab</link>
      <guid>https://dev.to/theoslater/building-polyui-a-local-first-ai-chat-client-with-tauri-and-rust-40ab</guid>
      <description>&lt;p&gt;I've spent the past several months building &lt;a href="https://polyui.co.uk" rel="noopener noreferrer"&gt;PolyUI&lt;/a&gt;, a desktop app for chatting with local LLMs through Ollama (it also works with OpenAI-compatible APIs). This post is a quick tour of how it's put together and why I made the choices I did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why another chat UI?
&lt;/h2&gt;

&lt;p&gt;Most local-LLM front ends lean on Docker, a Python environment, or both. That's fine for a lot of developers, but it's a lot of friction to hand to a less technical friend who just wants to try running a model locally. PolyUI is a single installer: no containers, no venvs, just an app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stack
&lt;/h2&gt;

&lt;p&gt;PolyUI is a Tauri v2 app: a React/TypeScript frontend bundled with a Rust backend, with SQLite (via tauri-plugin-sql) as the persistence layer.&lt;/p&gt;

&lt;p&gt;Frontend structure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Zustand stores, one per domain (auth, chat, models, settings, folders, theme). Stores don't import each other directly — cross-store effects go through a coordinator module so the dependency graph stays flat and testable.&lt;/li&gt;
&lt;li&gt;Self-contained feature modules (chat, auth, sidebar, models, ollama, settings, memory, providers, folders, web-search, etc).&lt;/li&gt;
&lt;li&gt;A lib layer with no UI: an EventBus interface with a Tauri implementation, a pure token-accumulator that batches streamed text via requestAnimationFrame, and a repository interface for conversations with both a SQLite and an in-memory implementation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Backend structure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Thin Tauri command adapters, with the real logic living in dedicated modules — a tool-calling loop that streams until completion, a StreamEmitter trait with a real Tauri implementation and a test-spy implementation, and separate modules for auth, memory, providers, and model management.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Data flow
&lt;/h2&gt;

&lt;p&gt;Rust emits typed Tauri events (chat-chunk, chat-thinking, web-search-event). The frontend's event bus subscribes to them, a stream accumulator batches the incoming tokens via requestAnimationFrame instead of re-rendering on every chunk, and React hooks consume the accumulator. Multi-model streams each get their own request_id so several models can stream side-by-side without stepping on each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing
&lt;/h2&gt;

&lt;p&gt;The trait-based seams (StreamEmitter, the repository interface) mean the streaming and persistence logic can be unit tested without spinning up an actual Tauri window — swap in the test spy or the in-memory repository and assert against pure logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does today
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Multi-model conversations with real-time, side-by-side streaming&lt;/li&gt;
&lt;li&gt;Full Markdown + LaTeX (KaTeX) rendering&lt;/li&gt;
&lt;li&gt;Installing Ollama models from inside the app&lt;/li&gt;
&lt;li&gt;Guest mode for temporary chats that never touch disk&lt;/li&gt;
&lt;li&gt;A handful of built-in system-prompt personas, or a custom one&lt;/li&gt;
&lt;li&gt;Everything stays on-device; nothing leaves your machine without explicit action&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's MIT licensed. Repo's here: &lt;a href="https://github.com/monolabsdev/poly-ui" rel="noopener noreferrer"&gt;https://github.com/monolabsdev/poly-ui&lt;/a&gt; — stars, issues, and PRs are all welcome, and I'd love to hear how the architecture holds up if anyone else is building something similar on Tauri.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>tauri</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>How I Implemented Supertonic TTS into My Desktop App, OpenBench AI</title>
      <dc:creator>Theo Slater</dc:creator>
      <pubDate>Mon, 25 May 2026 13:49:08 +0000</pubDate>
      <link>https://dev.to/theoslater/how-i-implemented-supertonic-tts-into-my-desktop-app-openbench-ai-4abg</link>
      <guid>https://dev.to/theoslater/how-i-implemented-supertonic-tts-into-my-desktop-app-openbench-ai-4abg</guid>
      <description>&lt;p&gt;I recently added text-to-speech (TTS) capabilities to OpenBench AI, my local-first Tauri desktop chat app. Rather than relying on a single TTS service, I built a dual-engine system that lets users choose between browser-native speech synthesis or a high-quality on-device neural model. Here's how I did it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Goal
&lt;/h2&gt;

&lt;p&gt;Users should be able to click a speaker icon on any assistant message and hear it read aloud. They should have options: lightweight and built-in, or more natural-sounding but requiring a larger model download. All processing should happen localy with no API calls, no privacy concerns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture: Dual Engines
&lt;/h2&gt;

&lt;p&gt;I implemented two TTS engines with a simple interface:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Supertonic (ST-TTS)&lt;/strong&gt; — On-device neural TTS. Produces natural, high-quality audio. Requires downloading a ~100MB ONNX model on first use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browser SpeechSynthesis&lt;/strong&gt; — The Web Speech API. No downloads, instant playback, slightly less natural, voice availability varies by OS.&lt;/p&gt;

&lt;p&gt;Both engines are exposed via a speaker icon on each message and configured in Settings &amp;gt; Speech.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Backend: tauri-plugin-supertonic
&lt;/h3&gt;

&lt;p&gt;A Rust Tauri 2 plugin (&lt;code&gt;tauri-plugin-supertonic@0.1&lt;/code&gt;) wrapping &lt;code&gt;supertonic-core&lt;/code&gt; and ONNX Runtime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why a plugin?&lt;/strong&gt; No custom Rust commands needed. The plugin handles everything at the Rust layer: model download, voice selection, and WAV synthesis. The frontend only speaks through the JavaScript API.&lt;/p&gt;

&lt;h3&gt;
  
  
  Frontend: tauri-plugin-supertonic-api
&lt;/h3&gt;

&lt;p&gt;An npm package (&lt;code&gt;tauri-plugin-supertonic-api@0.1&lt;/code&gt;) exposing a clean JavaScript interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;loadModel&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nf"&gt;synthesize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;language&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="c1"&gt;// Returns base64 WAV&lt;/span&gt;
&lt;span class="nf"&gt;listVoices&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Voice&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nf"&gt;selectVoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;voiceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  State Management: Two Zustand Stores
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;settingsStore.ts&lt;/strong&gt; — Holds TTS configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;TtsSettings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;browser&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stTts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="na"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;voice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
    &lt;span class="na"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="c1"&gt;// 0.5–2.0&lt;/span&gt;
    &lt;span class="na"&gt;pitch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="c1"&gt;// 0–2.0&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nl"&gt;stTts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;voice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
    &lt;span class="na"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Settings persist to localStorage with version migration — if you ever add a new setting, users don't break.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ttsStore.ts&lt;/strong&gt; — Manages playback state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;TtsState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;activeMessageId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="na"&gt;isLoading&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;
  &lt;span class="na"&gt;currentAudio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HTMLAudioElement&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="na"&gt;play&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="na"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Per-message tracking means each message bubble can independently show play/stop/loading state.&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Works End-to-End
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. User Clicks Speaker Icon
&lt;/h3&gt;

&lt;p&gt;Calls &lt;code&gt;ttsStore.play(messageId, text)&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Text Cleaning
&lt;/h3&gt;

&lt;p&gt;A &lt;code&gt;cleanTextForSpeech()&lt;/code&gt; function strips markdown, HTML, code blocks, and math notation. You don't want the TTS engine trying to pronounce &lt;code&gt;**bold**&lt;/code&gt; or &lt;code&gt;$\LaTeX{}$&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Strip markdown bold/italic&lt;/span&gt;
&lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\*\*(&lt;/span&gt;&lt;span class="sr"&gt;.+&lt;/span&gt;&lt;span class="se"&gt;?)\*\*&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;$1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Strip code blocks and inline code&lt;/span&gt;
&lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/``&lt;/span&gt;&lt;span class="err"&gt;`
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;endraw&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="s2"&gt;```/g, "")
text = text.replace(/`&lt;/span&gt;&lt;span class="p"&gt;(.&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="p"&gt;?)&lt;/span&gt;&lt;span class="s2"&gt;`/g, "$1")

// Strip HTML tags
text = text.replace(/&amp;lt;[^&amp;gt;]*&amp;gt;/g, "")

// etc.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Engine Dispatch
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Browser SpeechSynthesis
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Split text into sentences (simple regex split on &lt;code&gt;.&lt;/code&gt; or &lt;code&gt;!&lt;/code&gt; or &lt;code&gt;?&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;For each sentence, create a &lt;code&gt;SpeechSynthesisUtterance&lt;/code&gt; with the configured voice, rate, and pitch.&lt;/li&gt;
&lt;li&gt;Queue them with &lt;code&gt;window.speechSynthesis.speak()&lt;/code&gt; — the browser queues them automatically.&lt;/li&gt;
&lt;li&gt;Stop via &lt;code&gt;window.speechSynthesis.cancel()&lt;/code&gt;.
Pros: instant, no downloads, works everywhere.
Cons: less natural, voice quality varies by OS.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Supertonic (ST-TTS)
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Check if the model is loaded. If not, show a "Downloading TTS model (~100MB)" toast and call &lt;code&gt;loadModel()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Once ready, call &lt;code&gt;synthesize(text, "en")&lt;/code&gt; from the supertonic API.&lt;/li&gt;
&lt;li&gt;Get back a WAV file as base64.&lt;/li&gt;
&lt;li&gt;Play via &lt;code&gt;new Audio("data:audio/wav;base64,...").play()&lt;/code&gt;.
Pros: high-quality, consistent across platforms, on-device.
Cons: ~100MB download, slower synthesis.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;play&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;activeMessageId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;isLoading&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;settingsStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getState&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;engine&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cleanText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cleanTextForSpeech&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;browser&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;playBrowserTts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cleanText&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Ensure model is loaded&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;modelLoaded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;showToast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Downloading TTS model (~100MB)...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;supertonic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loadModel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nx"&gt;modelLoaded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wavBase64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;supertonic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;synthesize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;cleanText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Audio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`data:audio/wav;base64,&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;wavBase64&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="nx"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;play&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;currentAudio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;isLoading&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;showToast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;TTS failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;isLoading&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Stopping Playback
&lt;/h3&gt;

&lt;p&gt;The stop function pauses the current &lt;code&gt;HTMLAudioElement&lt;/code&gt; or cancels &lt;code&gt;window.speechSynthesis&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Also, when the user switches conversations, auto-stop fires — no audio bleeding into the next chat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Settings UI (SpeechTab.tsx)
&lt;/h2&gt;

&lt;p&gt;The Settings modal has a Speech tab with two sections:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Engine Selector&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;○ Browser (lightweight, instant)
◉ On-device (natural, ~100MB download)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;If Browser Selected&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Voice dropdown (sourced from &lt;code&gt;speechSynthesis.getVoices()&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Speed slider (0.5–2.0)&lt;/li&gt;
&lt;li&gt;Pitch slider (0–2.0)&lt;/li&gt;
&lt;li&gt;Test Voice button
&lt;strong&gt;If ST-TTS Selected&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Load Model button (shows progress; disabled if model is loading or loaded)&lt;/li&gt;
&lt;li&gt;Voice style selector (sourced from &lt;code&gt;listVoices()&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Speed slider (if supported)&lt;/li&gt;
&lt;li&gt;Test Voice button
Both tabs show a live playback preview so users hear the difference before committing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Key Design Decisions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  No Custom Rust Commands
&lt;/h3&gt;

&lt;p&gt;The plugin abstraction meant I could avoid writing custom Tauri command handlers. The plugin exposes everything through a clean npm package. If I ever need to add a new TTS feature (e.g., model switching, streaming), I just extend the plugin — the frontend doesn't need to change.&lt;/p&gt;

&lt;h3&gt;
  
  
  Web-Standard Audio Playback
&lt;/h3&gt;

&lt;p&gt;I didn't reach for a Tauri audio plugin. &lt;code&gt;HTMLAudioElement&lt;/code&gt; handles the WAV file from supertonic perfectly. &lt;code&gt;window.speechSynthesis&lt;/code&gt; is built-in. Both are reliable and require zero extra dependencies.&lt;/p&gt;

&lt;h3&gt;
  
  
  Per-Message Tracking
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;activeMessageId&lt;/code&gt; in the TTS store lets each message bubble track its own playback state independently. One message can be playing while another shows a "Play" button. This feels more natural than a global play/stop.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lazy Model Loading
&lt;/h3&gt;

&lt;p&gt;The ~100MB supertonic model only downloads when the user first clicks play with ST-TTS selected — not on app startup. A toast notifies them what's happening. Once loaded, subsequent plays are instant. This keeps the app lightweight until the feature is actually needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently (or Next)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Streaming synthesis&lt;/strong&gt; — For long messages, synthesizing the entire text at once can feel slow. Streaming chunks as they're generated would improve perceived performance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Voice caching&lt;/strong&gt; — Cache synthesized audio so replaying the same message is instant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limiting&lt;/strong&gt; — If ST-TTS synthesis is slow, queue requests or show a "Synthesizing..." state more explicitly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-platform voice testing&lt;/strong&gt; — Browser voice availability varies wildly (Windows, Mac, Linux). Testing coverage is key.
## Wrapping Up&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Dual-engine TTS isn't complicated, but it requires thoughtful state management and a clean architecture. By separating concerns (settings, playback, engine logic) and leaning on the plugin system, I ended up with a feature that's flexible, performant, and maintainable.&lt;/p&gt;

&lt;p&gt;If you're building a Tauri app and want to add TTS, this pattern should translate directly. The key takeaway: abstract your engine behind a simple interface, lazy-load heavy resources, and let the user choose.&lt;/p&gt;

&lt;p&gt;Feel free to check it out at: &lt;a href="https://github.com/monolabsdev/openbench-ai" rel="noopener noreferrer"&gt;https://github.com/monolabsdev/openbench-ai&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>react</category>
      <category>rust</category>
    </item>
    <item>
      <title>I Built a Desktop Chat App for Running Local LLMs Offline</title>
      <dc:creator>Theo Slater</dc:creator>
      <pubDate>Wed, 20 May 2026 15:16:48 +0000</pubDate>
      <link>https://dev.to/theoslater/i-built-a-desktop-chat-app-for-running-local-llms-offline-6im</link>
      <guid>https://dev.to/theoslater/i-built-a-desktop-chat-app-for-running-local-llms-offline-6im</guid>
      <description>&lt;p&gt;A few months ago I got tired of bouncing between ChatGPT, Claude, and a dozen other AI chat UIs every time I wanted to test a different model. It felt like productivity, if productivity involved tab overload and constant context switching.&lt;/p&gt;

&lt;p&gt;I also wanted to run everything locally without sending data off to someone else’s server just to ask a question about JSON formatting.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;Openbench AI&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it is
&lt;/h2&gt;

&lt;p&gt;OpenBench is a desktop chat app that connects to Ollama and lets you talk to local LLMs without the usual ritual of Docker, Python environments, or “why is this port not working again” debugging sessions.&lt;/p&gt;

&lt;p&gt;You install it. You open it. You chat.&lt;/p&gt;

&lt;p&gt;That’s it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key features
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Multi-model chat (side by side comparison)
&lt;/h3&gt;

&lt;p&gt;You can run multiple models at once and watch them respond in real time to the same prompt.&lt;/p&gt;

&lt;p&gt;Instead of guessing which model is best, you can just compare them directly under identical conditions like a mildly scientific experiment that occasionally exposes how inconsistent models can be.&lt;/p&gt;

&lt;h3&gt;
  
  
  Markdown + LaTeX support
&lt;/h3&gt;

&lt;p&gt;Full rendering via KaTeX.&lt;/p&gt;

&lt;p&gt;So when a model writes equations or structured explanations, it actually looks like something a human might willingly read.&lt;/p&gt;

&lt;h3&gt;
  
  
  Guest mode
&lt;/h3&gt;

&lt;p&gt;Temporary chats that disappear when you close the app.&lt;/p&gt;

&lt;p&gt;No storage. No accounts. No memory overhead.&lt;/p&gt;

&lt;p&gt;Just quick experiments without commitment.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ollama model management
&lt;/h3&gt;

&lt;p&gt;You can install models directly inside the app instead of juggling terminal commands and hoping you typed everything correctly the first time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Conversation tools
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Archive chats&lt;/li&gt;
&lt;li&gt;System prompt presets&lt;/li&gt;
&lt;li&gt;CUstom system prompt&lt;/li&gt;
&lt;li&gt;Clean interface without unnecessary clutter.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Tech stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Tauri&lt;/li&gt;
&lt;li&gt;Zustand&lt;/li&gt;
&lt;li&gt;MUI&lt;/li&gt;
&lt;li&gt;SQLite&lt;/li&gt;
&lt;li&gt;ollama-rs&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What i would change
&lt;/h2&gt;

&lt;p&gt;If I rebuilt it, I’d add OpenAI-compatible API support from day one.&lt;/p&gt;

&lt;p&gt;Right now it only works with Ollama, though the architecture already supports additional providers. I just haven’t wired them in yet because priorities are a fictional concept.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open source
&lt;/h2&gt;

&lt;p&gt;The project is open source, and release builds are available if you want to try it without compiling anything yourself.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/monolabsdev/openbench-ai" rel="noopener noreferrer"&gt;Openbench GitHub&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>ollama</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I Built a Desktop Chat App for Running Local LLMs Offline</title>
      <dc:creator>Theo Slater</dc:creator>
      <pubDate>Wed, 20 May 2026 15:16:48 +0000</pubDate>
      <link>https://dev.to/theoslater/i-built-a-desktop-chat-app-for-running-local-llms-offline-21cm</link>
      <guid>https://dev.to/theoslater/i-built-a-desktop-chat-app-for-running-local-llms-offline-21cm</guid>
      <description>&lt;p&gt;A few months ago I got tired of bouncing between ChatGPT, Claude, and a dozen other AI chat UIs every time I wanted to test a different model. It felt like productivity, if productivity involved tab overload and constant context switching.&lt;/p&gt;

&lt;p&gt;I also wanted to run everything locally without sending data off to someone else’s server just to ask a question about JSON formatting.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;Openbench AI&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it is
&lt;/h2&gt;

&lt;p&gt;OpenBench is a desktop chat app that connects to Ollama and lets you talk to local LLMs without the usual ritual of Docker, Python environments, or “why is this port not working again” debugging sessions.&lt;/p&gt;

&lt;p&gt;You install it. You open it. You chat.&lt;/p&gt;

&lt;p&gt;That’s it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key features
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Multi-model chat (side by side comparison)
&lt;/h3&gt;

&lt;p&gt;You can run multiple models at once and watch them respond in real time to the same prompt.&lt;/p&gt;

&lt;p&gt;Instead of guessing which model is best, you can just compare them directly under identical conditions like a mildly scientific experiment that occasionally exposes how inconsistent models can be.&lt;/p&gt;

&lt;h3&gt;
  
  
  Markdown + LaTeX support
&lt;/h3&gt;

&lt;p&gt;Full rendering via KaTeX.&lt;/p&gt;

&lt;p&gt;So when a model writes equations or structured explanations, it actually looks like something a human might willingly read.&lt;/p&gt;

&lt;h3&gt;
  
  
  Guest mode
&lt;/h3&gt;

&lt;p&gt;Temporary chats that disappear when you close the app.&lt;/p&gt;

&lt;p&gt;No storage. No accounts. No memory overhead.&lt;/p&gt;

&lt;p&gt;Just quick experiments without commitment.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ollama model management
&lt;/h3&gt;

&lt;p&gt;You can install models directly inside the app instead of juggling terminal commands and hoping you typed everything correctly the first time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Conversation tools
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Archive chats&lt;/li&gt;
&lt;li&gt;System prompt presets&lt;/li&gt;
&lt;li&gt;CUstom system prompt&lt;/li&gt;
&lt;li&gt;Clean interface without unnecessary clutter.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Tech stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Tauri&lt;/li&gt;
&lt;li&gt;Zustand&lt;/li&gt;
&lt;li&gt;MUI&lt;/li&gt;
&lt;li&gt;SQLite&lt;/li&gt;
&lt;li&gt;ollama-rs&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What i would change
&lt;/h2&gt;

&lt;p&gt;If I rebuilt it, I’d add OpenAI-compatible API support from day one.&lt;/p&gt;

&lt;p&gt;Right now it only works with Ollama, though the architecture already supports additional providers. I just haven’t wired them in yet because priorities are a fictional concept.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open source
&lt;/h2&gt;

&lt;p&gt;The project is open source, and release builds are available if you want to try it without compiling anything yourself.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/monolabsdev/openbench-ai" rel="noopener noreferrer"&gt;Openbench GitHub&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing note
&lt;/h2&gt;

&lt;p&gt;Somewhere between building multi-model streaming and debugging Rust event bridges, I learned that the world did not strictly need another chat UI.&lt;/p&gt;

&lt;p&gt;So I made one anyway.&lt;/p&gt;

&lt;p&gt;For what it’s worth, it behaves better than most tools that have significantly more funding and optimism.&lt;/p&gt;

&lt;p&gt;And yes, it works just fine for a gay developer trying to wrangle multiple AI models without losing their mind to tool fragmentation.&lt;/p&gt;

&lt;p&gt;Which is probably more honesty than most software documentation is legally allowed to contain.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>ollama</category>
      <category>opensource</category>
    </item>
    <item>
      <title>[Boost]</title>
      <dc:creator>Theo Slater</dc:creator>
      <pubDate>Sat, 11 Apr 2026 17:26:05 +0000</pubDate>
      <link>https://dev.to/theoslater/-3d73</link>
      <guid>https://dev.to/theoslater/-3d73</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/peixotomdb/i-got-tired-of-using-10-different-dev-tools-so-i-built-my-own-2gl4" class="crayons-story__hidden-navigation-link"&gt;I got tired of using 10 different dev tools, so I built my own&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/peixotomdb" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1019001%2Fac7ddfda-b0b5-4608-9f04-9a4155383488.jpeg" alt="peixotomdb profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/peixotomdb" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Peixoto
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Peixoto
                
              
              &lt;div id="story-author-preview-content-3487106" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/peixotomdb" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1019001%2Fac7ddfda-b0b5-4608-9f04-9a4155383488.jpeg" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Peixoto&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/peixotomdb/i-got-tired-of-using-10-different-dev-tools-so-i-built-my-own-2gl4" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Apr 11&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/peixotomdb/i-got-tired-of-using-10-different-dev-tools-so-i-built-my-own-2gl4" id="article-link-3487106"&gt;
          I got tired of using 10 different dev tools, so I built my own
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag crayons-tag--filled  " href="/t/showdev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;showdev&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/webdev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;webdev&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/productivity"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;productivity&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/programming"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;programming&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/peixotomdb/i-got-tired-of-using-10-different-dev-tools-so-i-built-my-own-2gl4" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;7&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/peixotomdb/i-got-tired-of-using-10-different-dev-tools-so-i-built-my-own-2gl4#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              2&lt;span class="hidden s:inline"&gt;&amp;nbsp;comments&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            5 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
    </item>
  </channel>
</rss>
