Category: OpenClaw

  • Building a Vue 3 Chat UI with Streaming LLM Responses (OpenClaw + Node.js + MariaDB)

    The Chat UI Project Timeline (May–July 2026)

    What started as a quick experiment to talk to a local LLM through OpenClaw slowly turned into something much larger than expected — a full chat application with streaming responses, authentication, persistent sessions, and eventually a modular Vue 3 frontend backed by Node.js and MariaDB.

    None of this was planned in advance. It grew the way most working systems do: one useful feature at a time, until the structure had to catch up.


    When it was just a single file (May 17–30)

    The first version was almost embarrassingly simple.

    A single HTML file. No framework. No build system. Just direct calls to OpenClaw’s /v1/chat/completions endpoint and a bit of JavaScript to make it feel interactive.

    It had just enough to feel real:

    • dark/light mode toggle using localStorage
    • a basic settings panel (API URL, model name, gateway config)
    • markdown rendering for responses
    • syntax highlighting for code blocks
    • streaming output that felt surprisingly responsive

    It ran locally through:

    python -m http.server

    And for a moment, that was enough.

    The one obvious limitation was also the one you could ignore at first: nothing persisted. Refresh the page, everything was gone. But at that stage, persistence wasn’t the point — proving the streaming pipeline was.


    The moment it started growing out of control (June 1)

    The problem wasn’t any single feature — it was that everything kept feeling “just one more step away” from being useful.

    So features accumulated:

    • a “thinking…” indicator while waiting for first token
    • streaming cursor animation
    • copy buttons (both plain and formatted text)
    • export options (HTML / JSON / text)
    • inline chat renaming
    • light mode fixes caused by CSS variable timing issues
    • session persistence bugs from object rehydration mismatches

    At the same time, the backend quietly stopped being a toy.

    The original Python proxy started showing instability, so it was replaced with a Node.js server. That shift ended up being bigger than expected:

    • stable API routing
    • proper REST endpoints
    • static file serving
    • rate limiting for login attempts
    • MariaDB-backed session storage
    • bcrypt password hashing

    At that point, the project stopped feeling like a frontend experiment and started feeling like a real system.

    But the frontend was clearly reaching its limit. Everything lived together in the same place — streaming logic, UI state, DOM manipulation, and configuration handling all overlapping in ways that made every change slightly risky.


    Vue 3 changes the way everything feels (Mid-June)

    Moving to Vue wasn’t just a refactor — it changed the mental model of the entire app.

    Instead of one growing script, there were now components:

    • Login handled authentication cleanly
    • Chat became an orchestrator instead of a dumping ground
    • routing replaced hash-based navigation
    • reactive state replaced manual DOM updates

    Streaming responses were the first thing that immediately improved. Instead of manually pushing chunks into the DOM, Vue reacted to state changes naturally.

    It wasn’t perfect — there were still rough edges:

    • thinking animation behavior needed adjustment
    • partial stream chunks occasionally rendered awkwardly
    • old imperative patterns kept bleeding into components

    But the structure was finally strong enough that problems became fixable instead of structural.


    When it stopped being single-user (June 12–17)

    Authentication changed the shape of the project.

    What started as “just let me log in” turned into a full user system:

    • session cookies (HTTP-only)
    • bcrypt password hashing
    • rate limiting on login attempts
    • MariaDB-backed auth sessions
    • per-user configuration

    And with that, the UI started to shift subtly too.

    “Model selection” stopped feeling like the right term. It became “Agent selection” — a small naming change, but it made the interface feel more aligned with how it was actually being used.

    The UI also started consolidating. Export and print features that were previously scattered across the interface were pulled into a single clean dropdown in the header. Small change, but it reduced a lot of visual noise.


    The point where refactoring became unavoidable (July 6)

    By early July, Chat.vue had grown past 1600 lines.

    At that point it wasn’t a question of whether to refactor — it was just a question of how painful it would be.

    Everything lived in it:

    • message rendering
    • streaming logic
    • session management
    • input handling
    • header + sidebar coordination
    • UI state transitions

    So the breakdown started slowly.

    HeaderBar was extracted first — taking title editing, theme toggles, settings, logout, export, and user display with it. Then the input area. Then thinking indicator. Then sidebar components were split into smaller responsibilities.

    Once that happened, something interesting became obvious: the complexity didn’t disappear — it just stopped colliding with itself.

    Even utility logic followed the same pattern:

    • theme handling moved into theme.js
    • settings and persistence moved into settings.js

    Nothing revolutionary — just finally separating things that had no business living together.


    Small UX fixes that mattered more than expected (July 6 — current)

    Once the structure stabilized, the changes became smaller but more noticeable.

    Copy buttons inside messages were redesigned into subtle hover-only controls:

    • 📋 copy full message
    • 📝 copy formatted text
    • ✓ temporary confirmation feedback

    Instead of taking up space, they now only appear when needed. The conversation stays visually clean, and the actions stay available without being intrusive.


    The sidebar bug that revealed the real issue

    One bug stood out because it felt like it shouldn’t have been a bug.

    Renaming a chat from the header didn’t immediately update the sidebar.

    Nothing was wrong with the API. The backend was updating correctly. The issue was simpler: the sidebar and header were not actually looking at the same reactive source.

    Once the session object inside the shared array was mutated directly, Vue handled the rest automatically. No extra events, no forced refresh — just proper state flow.

    It was one of those fixes that feels almost obvious in hindsight.


    Where it ended up

    As of July 6, 2026, the system has become a full-stack chat application:

    Frontend (Vue 3)

    • Login system
    • Header controls
    • Chat orchestration layer
    • Markdown + streaming message renderer
    • Input system
    • Sidebar session manager

    Backend

    • Node.js proxy server (REST + static hosting)
    • MariaDB session storage (JSON chat history)
    • bcrypt authentication system
    • OpenClaw gateway integration
    • llama.cpp backend (Qwen3.6-35B-A3B model)

    Everything runs containerized with multiple services coordinated together.


    Closing reflection

    The project didn’t follow a planned architecture. It didn’t start with a design document or a long-term structure in mind.

    It evolved through pressure.

    Features got added because they were useful. Structure got introduced when the lack of it started to hurt. Refactoring happened when change became too expensive without it.

    And the most consistent lesson throughout wasn’t about Vue, or Node, or even system design.

    It was this:

    You don’t really design systems like this in advance.
    You discover their shape by pushing them until they break — and then reshaping them just enough so they stop breaking in the same way.

  • Benchmarking MTP on an RTX 3090: What Multi-Token Prediction Actually Speeds Up

    After finally getting Multi-Token Prediction working in llama.cpp, I had a simple question:

    Was it actually faster?

    It’s easy to convince yourself that a new optimization is helping. You make a change, run a few prompts, and the model feels faster. But perception is not measurement.

    I wanted numbers.

    I had just learned the hard way that MTP requires a model that actually contains MTP layers — my initial setup using unsloth/Qwen3.6-35B-A3B-UD-Q4_K_XL.gguf was silently falling back to ordinary decoding. Switching to the MTP variant from bartowski/Qwen_Qwen3.6-35B-A3B-GGUF made the system actually run speculative decoding. But I still didn’t know if it was helping.

    So I built two nearly identical configurations:

    Everything else stayed fixed:

    • Same RTX 3090
    • Same llama.cpp build
    • Same context size
    • Same prompts
    • Same server configuration
    • Same cache settings

    From there, I built three benchmarks to isolate different parts of the inference pipeline.


    Benchmark Design

    The goal was to separate the system into three measurable phases:

    • Decode Test → generation throughput
    • Prefill Test → prompt ingestion speed
    • Mixed Test → realistic workload behavior

    This separation matters because speculative decoding only affects generation, not prompt processing.

    One important detail: for the decode and mixed tests, I measure completion tokens per second (generation phase only), since that’s what MTP affects. The prefill test uses total tokens, since it’s measuring prompt processing speed.


    Test Definitions

    1. Decode Test (Generation Throughput)

    • Prompt: ~50–60 tokens
    • Completion: ~1500 tokens
    • Metric: completion_tokens / elapsed (generation TPS)

    Purpose: measure pure decoding speed with minimal overhead.


    2. Prefill Test (Context Ingestion)

    • Prompt: ~55,000 tokens
    • Completion: ~10–20 tokens
    • Metric: total_tokens / elapsed (prompt eval TPS)

    Purpose: measure how fast the model processes long context windows.


    3. Mixed Test (Realistic Workload)

    • Prompt: ~13,000 tokens
    • Completion: ~800 tokens
    • Metric: completion_tokens / elapsed (generation TPS)

    Purpose: approximate real-world coding/chat usage.


    Initial Results

    Decode Test

    • Non-MTP: 132 tokens/sec
    • MTP: 175 tokens/sec
    • Improvement: +32%

    Clear gain in pure generation workloads.


    Mixed Test

    • Non-MTP: 116 tokens/sec
    • MTP: 146 tokens/sec
    • Improvement: +26%

    Realistic workloads still benefit significantly.


    Prefill Test

    • Non-MTP: ~39 tokens/sec
    • MTP: ~39 tokens/sec
    • Improvement: ~0%

    No meaningful change.

    This confirmed a key point early:

    MTP accelerates generation, not prompt processing.


    What This Actually Means

    MTP is not a general inference speedup.

    It is a generation-side optimization only.

    So its impact depends entirely on workload shape:

    • Chatty / long generation → large gains
    • Prompt-heavy workloads → little to no benefit

    For my setup (RTX 3090 + Qwen3.6-35B-A3B), the gain is substantial—but not universal.


    Controlled Re-Run (Validation Pass)

    After the initial results, I reran the benchmarks with stricter controls to remove ambiguity:

    Standardized changes:

    • Fixed context size to 65,536
    • Removed multimodal projection from baseline
    • Set spec-draft-n-max = 4 for MTP runs

    The goal was not to change behavior, but to remove noise.


    Decode Test

    • Non-MTP: ~131–134 tokens/sec
    • MTP: ~174–175 tokens/sec
    • Improvement: +30–33%

    Mixed Test

    • Non-MTP: ~116–117 tokens/sec
    • MTP: ~144–146 tokens/sec
    • Improvement: +24–26%

    Prefill Test

    • Non-MTP: ~37–40 tokens/sec
    • MTP: ~38–40 tokens/sec
    • Improvement: ~0%

    Draft Acceptance (MTP)

    Observed across runs:

    • ~82–88% draft token acceptance
    • Stable speculative decoding behavior
    • Consistent acceleration during generation

    The higher acceptance rate compared to the initial 67–70% runs is likely due to the fixed context size (65,536) and spec-draft-n-max = 4 providing more consistent conditions for draft predictions.


    Reproducible Benchmark Harness

    To avoid subjective testing, I wrapped all benchmarks into a repeatable harness that isolates inference phases.

    Bash Runner

    #!/usr/bin/env bash
    
    SERVER="http://localhost:8080/v1/completions"
    OUTPUT_CSV="benchmark_results.csv"
    
    if [ ! -f "$OUTPUT_CSV" ]; then
      echo "timestamp,test,elapsed_sec,prompt_tokens,completion_tokens,total_tokens,tps" > "$OUTPUT_CSV"
    fi
    
    run_test () {
      NAME=$1
      PROMPT_FILE=$2
      MAX_TOKENS=$3
    
      START=$(date +%s.%N)
    
      RESPONSE=$(curl -s $SERVER \
        -H "Content-Type: application/json" \
        -d <<EOF
    {
      "model": "qwen",
      "prompt": "$(cat $PROMPT_FILE)",
      "max_tokens": $MAX_TOKENS,
      "temperature": 0.0
    }
    EOF
    )
    
      END=$(date +%s.%N)
    
      ELAPSED=$(echo "$END - $START" | bc)
    
      PROMPT_TOKENS=$(echo "$RESPONSE" | jq '.usage.prompt_tokens')
      COMPLETION_TOKENS=$(echo "$RESPONSE" | jq '.usage.completion_tokens')
      TOTAL_TOKENS=$(echo "$PROMPT_TOKENS + $COMPLETION_TOKENS" | bc)
    
      # For decode/mixed tests: use completion_tokens (generation-only TPS)
      # For prefill tests: use total_tokens (prompt eval TPS)
      TPS=$(echo "$COMPLETION_TOKENS / $ELAPSED" | bc -l)
    
      TIMESTAMP=$(date -Iseconds)
    
      echo "$TIMESTAMP,$NAME,$ELAPSED,$PROMPT_TOKENS,$COMPLETION_TOKENS,$TOTAL_TOKENS,$TPS" >> "$OUTPUT_CSV"
    
      echo "Running: $NAME"
      echo "TPS (generation): $TPS"
      echo
    }
    
    run_test "Decode" decode_prompt.txt 1500
    run_test "Prefill" prefill_prompt.txt 16
    run_test "Mixed" mixed_prompt.txt 800

    Aggregation Script

    To compute averages across runs:

    awk -F',' '
    NR>1 {
      t[$2]++
      sum[$2]+=$7
    }
    END {
      for (i in t) {
        printf "%s → avg TPS: %.2f (%d runs)\n", i, sum[i]/t[i], t[i]
      }
    }
    ' benchmark_results.csv

    Why This Matters

    Without structured measurement, it’s easy to misinterpret performance changes:

    • cache state affects prefill timing
    • single runs exaggerate decode gains
    • mixed workloads vary wildly with prompt composition

    This setup forces a controlled comparison:

    same inputs → different decoding strategy → measurable delta


    Conclusion

    The takeaway is not just that MTP improves performance.

    It’s where it improves performance.

    • Decode: consistently ~30% faster
    • Mixed workloads: ~25% faster
    • Prefill: unchanged

    That makes MTP a generation accelerator, not a universal speedup.

    And that distinction is the most important result of the entire experiment.

  • Discovering MTP: How I Confused Configuration With Functionality

    I’ve spent a lot of time tuning local LLMs over the last few months, but every now and then I run into an optimization that forces me to stop tweaking settings and actually understand how something works.

    Recently that optimization was MTP.

    I was running Qwen3.6-35B-A3B on an RTX 3090 and started seeing references to Multi-Token Prediction (MTP) and speculative decoding in llama.cpp. The promise was enticing: generate multiple tokens ahead of time and verify them in batches, potentially producing a significant speedup during inference.

    Like many people, I assumed the hard part would be finding the right command-line arguments.

    I was wrong.


    The Setup

    My baseline configuration was:

    The model already performed well, but long coding prompts exposed a familiar problem: latency under sustained generation.

    Speculative decoding looked like the obvious next step.


    The First Mistake: Treating Configuration as Evidence

    My first experiments focused on enabling speculative decoding through llama.cpp’s runtime options. I added the appropriate flags, restarted the server, and started watching logs.

    At first glance everything looked promising. The server started. Requests completed successfully. I even saw references to speculative decoding in various places throughout the codebase and documentation.

    The problem was that I never actually confirmed whether MTP was running.

    I was treating the configuration as evidence.

    Over the next several hours I went through a familiar optimization cycle: change a setting, run a test, examine logs, repeat. Sometimes performance improved. Sometimes it didn’t. The results were noisy enough that I couldn’t confidently explain what was happening.

    The breakthrough came when I stopped looking at throughput numbers and started reading startup logs line by line.

    One log entry stood out:

    context type MTP requested but model doesn't contain MTP layers
    failed to create MTP context

    That single message completely changed my understanding of the problem.

    Up until that point I had been assuming that enabling:

    --spec-type draft-mtp

    somehow activated MTP behavior.

    What the logs revealed was much simpler.

    The flag does not create MTP capability. It merely tells llama.cpp to use MTP if the model already contains MTP layers.

    My model didn’t.

    I had been asking llama.cpp to use a feature that wasn’t present in the model itself.


    The Fix: Switching Models

    Once I understood that distinction, everything else fell into place. I located a version of the model that actually contained MTP layers and switched over to it.

    The MTP variant comes from bartowski/Qwen_Qwen3.6-35B-A3B-GGUF — the same repo as the standard quantized models, but with a separate file that includes the MTP layers needed for speculative decoding.

    The startup logs immediately looked different:

    estimated memory usage of MTP context is 232 MiB
    creating MTP draft context
    speculative decoding context initialized

    These were messages I had never seen before. For the first time, llama.cpp was actually creating an MTP context instead of silently falling back to ordinary decoding.


    What I Initially Misunderstood About MTP

    During early testing, I conflated several distinct parts of the system. Understanding these distinctions is what makes further tuning predictable rather than experimental.

    1. draft-mtp is not a full system switch

    I initially treated

    --spec-type draft-mtp

    as if it enabled a complete MTP mode on its own. In reality, it is only a speculative decoding backend selection — a flag that tells llama.cpp to use MTP if the model supports it. The model must already contain MTP layers for anything to happen.

    2. The MTP model variant is separate from the algorithm

    The model file from bartowski’s repo that includes MTP layers is a build that has been packaged with the additional MTP layers needed for speculative decoding. Using this model enables the algorithm to function, but the two concepts are distinct:

    • The algorithm (draft-mtp) controls how draft tokens are generated and validated during inference
    • The model provides the MTP layers that the algorithm needs to work

    This is easy to confuse because llama.cpp exposes them as separate knobs.

    3. KV cache behavior is more nuanced than it appears

    I previously interpreted internal log messages suggesting

    cache_k=f16
    cache_v=f16

    as meaning the entire system had switched KV cache formats. This was incorrect.

    That configuration applies only to the internal speculative draft context, not the main model. In my setup, the main model explicitly uses q4_0 KV cache. The MTP draft context uses f16 for its own internal operations, which is a separate allocation.


    What Actually Changed Performance

    After enabling draft-mtp and switching to the MTP variant model, the system stabilized and became measurably faster.

    Typical results looked like:

    • ~67–70% draft token acceptance rate
    • ~90–100 tokens/sec generation speed
    • consistent performance even at ~30k+ prompt lengths

    This confirmed that speculative decoding was actively contributing to throughput, not just enabled in name.


    Key Insights from Tuning

    Acceptance rate matters most

    The most useful signal turned out to be the draft acceptance rate. In my setup, the optimal range was:

    ~65–70% acceptance

    Lower values tend to waste compute on rejected drafts, while higher values often indicate insufficient speculative coverage.

    Context size dominates memory behavior

    At ~81k tokens, KV cache usage becomes a major factor in performance. This has more impact on stability than most speculative decoding parameters.

    MoE models behave differently

    Using a mixture-of-experts model changes speculative decoding dynamics:

    • token prediction is less uniform than dense models
    • acceptance rates are naturally lower
    • performance gains are real, but more modest and sensitive to tuning

    GPU offloading needs flexibility

    An early configuration forcing full GPU offload caused memory pressure:

    --n-gpu-layers 999

    Switching to:

    --n-gpu-layers -1

    allowed llama.cpp to automatically balance layer placement, improving stability without reducing performance.


    Final Configuration

    No external draft model was used.


    Conclusion

    The most important lesson from this process wasn’t about squeezing out a few extra tokens per second — it was about understanding the structure of the system itself.

    Modern llama.cpp speculative decoding is no longer a single, monolithic feature. It operates across multiple layers:

    • a decoding algorithm (draft-mtp)
    • a required MTP model variant
    • and runtime optimizations that adapt dynamically based on workload

    Once those layers are separated conceptually, the tuning process becomes much clearer — and far less confusing than the flags initially suggest.

    What struck me most about this experience was how easy it is to confuse configuration with functionality. Modern inference servers expose hundreds of knobs and switches, and it’s tempting to assume that enabling a feature means that feature is active.

    In reality, the logs were telling the truth the entire time. I just wasn’t asking the right question.

    Instead of asking, “Did I set the flag correctly?” I should have been asking, “Can I prove the feature is actually running?”

    Once I started looking for proof instead of configuration, the answer became obvious.

  • My Unexpected Journey From AI User to Systems Administrator

    When I first became interested in running AI locally, I had a very simple goal: I wanted an assistant. Not a research project, not a homelab experiment, not a collection of interconnected services spread across multiple containers. I just wanted a capable AI system that ran on my own hardware, respected my privacy, and was available whenever I needed it. At the time, that seemed like a fairly modest ambition.

    The basic recipe appeared straightforward enough. Download a model, install some software, connect a user interface, and start chatting. Every tutorial made the process look manageable. Every YouTube video seemed to end with someone happily talking to a model running on their desktop. I assumed I would eventually arrive at the same destination. In a sense, I did. I just took a much stranger route than I expected.

    The first few days were exactly what I had imagined. I experimented with different models, compared responses, and learned the basics of running inference locally. There was something satisfying about watching my own hardware generate answers without relying on external services. Every improvement felt tangible—a better model produced better responses, a larger context window remembered more information. Progress was easy to recognize.

    What I didn’t notice at the time was that every improvement was quietly increasing the complexity of the system underneath me. The first sign of this came when I started looking at memory. Like many people using AI regularly, I quickly discovered that forgetting is one of the biggest limitations of conversational systems. Having to repeat information in every session gets old surprisingly fast. Naturally, I started investigating ways to give the assistant long-term memory.

    That seemed like a feature. What I didn’t realize was that it was actually an entirely new subsystem. Suddenly I was reading about embeddings, retrieval pipelines, indexing strategies, and vector databases. The assistant still looked the same from the outside, but behind the scenes a whole new collection of moving parts had appeared. Information now had to be processed, stored, searched, and maintained. The system wasn’t simply generating answers anymore—it was managing information. At some point I stopped being an AI user and became the person responsible for keeping the memory system alive. The transition was subtle enough that I barely noticed it happening.

    The same thing occurred with model serving. Early on, I treated model backends as implementation details. Ollama, llama.cpp, and similar tools all appeared to solve the same basic problem: they loaded models and generated responses. From a user’s perspective, they were interchangeable. From an operator’s perspective, they were not.

    The deeper I went, the more I found myself caring about things that had never crossed my mind before. Resource allocation. GPU utilization. Context management. Startup parameters. Container behavior. Suddenly I wasn’t evaluating software based solely on what it could do. I was evaluating it based on how predictable it was, how easy it was to troubleshoot, and how well it behaved under real-world conditions. Without realizing it, I had started thinking like an administrator.

    One of the strangest moments came when I realized how much time I was spending reading logs. When I began this project, I viewed logs as something you consulted after a failure. If a service crashed, you checked the logs to determine why. That seemed reasonable enough. Months later, I found myself reading logs even when nothing was obviously wrong—looking at startup messages to understand context allocation, examining memory usage, investigating warnings that might or might not matter. I was trying to understand why a system behaved a certain way rather than simply whether it was functioning. That isn’t user behavior. That’s administrator behavior.

    The same pattern appeared elsewhere. I started caring about backup strategies for configuration files. I became interested in container restart behavior. I found myself thinking about maintenance windows and scheduled jobs. When a service update became available, my first thought wasn’t whether it introduced new features. My first thought was whether it might break something that currently worked.

    Nobody gets excited about AI because they want to spend their evenings thinking about maintenance windows. Yet there I was, doing exactly that.

    Part of the reason this transformation happens, I think, is that local AI systems are deceptively simple from the outside. The interface presents a single assistant. You type a question. You receive an answer. The experience feels unified. Behind the scenes, however, there is often an entire ecosystem making that interaction possible—inference servers loading models into memory, databases storing information, embedding systems generating vectors, containers, APIs, schedulers, storage systems, and configuration layers all working together to produce what appears to be a single conversation.

    As the system grows, someone has to understand how those pieces interact. If you’re running everything yourself, that someone is you.

    What surprised me most wasn’t the complexity itself—technology is often complicated. What surprised me was how quickly my priorities changed. Early on, I was obsessed with capability. I wanted better models, larger contexts, improved reasoning, and more advanced features. Over time, I became increasingly interested in stability. A feature that works ninety-nine percent of the time sounds impressive until you’re the person responsible for the remaining one percent. Suddenly reliability starts looking a lot more attractive than cutting-edge functionality. Predictable behavior becomes more valuable than clever behavior. You begin appreciating software that quietly does its job and stays out of your way. Those are the kinds of opinions that systems administrators tend to develop. I never expected local AI to teach me that lesson.

    Looking back, I think the most interesting part of this journey is that my original goal never actually changed. I still wanted an assistant. I still wanted a useful AI system running on my own hardware. The destination remained remarkably consistent. What changed was my understanding of what it takes to get there.

    The assistant was never just a model. It was a model, a serving layer, a memory system, a collection of databases, a retrieval pipeline, a storage layer, a scheduling mechanism, and a growing assortment of supporting infrastructure. The more capable I wanted the assistant to become, the more infrastructure appeared behind it. Every new capability required someone to keep that infrastructure running. Gradually, almost accidentally, that became my job.

    The funny thing is that I don’t regret it. The project has occasionally been frustrating—there have certainly been moments where I questioned why a seemingly simple goal required so much troubleshooting. Yet the process has also taught me far more about how modern software systems actually work than I expected when I started. I originally thought I was learning about AI. In reality, I was learning about systems. The AI just happened to be the reason I started paying attention.