跳到正文
DEV Community· Crane Zhou·· 4 小時前AI 評分56

EroLove 179 KB 內嵌聊天程式碼檢查揭示四項架構問題

I Read a 179KB Inline Chat Bundle on an AI Companion App: 4 API Bugs the UI Hid

AI 導讀

The Companion Lab 檢查 EroLove 的 179 KB 內嵌聊天程式碼及 API,揭示四項架構問題。問題包括上傳功能旗標與後端路由不一致、LLM 記憶抽取未遵從使用者語言,以及政策拒絕與佇列故障共用錯誤提示。另發現新生成圖片的 404 會被 CDN 快取;Premium 雖標示「Unlimited messages」,me.dailyLimit 顯示每日上限為 800。

正文

When we test AI companion apps at The Companion Lab, we normally treat them as black boxes: we send the same six standardized prompts, pay with our own bank card, record the exact credit card descriptor, and walk the cancellation screens.

Last week, the CTO of EroLove reached out and granted our test account a month of Premium so we could run our benchmark (full EroLove review and disclosure here). While inspecting the chat page in DevTools, I noticed something unusual: instead of a chunked Webpack or Vite build, the entire chat client ships as a single 179 KB inline JavaScript bundle inside the HTML document, talking to an undocumented JSON API.

Reading that 179 KB bundle—and probing the endpoints it calls—turned up four architectural bugs that a normal UI walkthrough would either misdiagnose or miss completely. Here is what the client code and network responses revealed, and why each failure mode matters if you are building an LLM-backed web application.


1. Feature Flags vs. Static SEO Copy: 30 Pages Promising an Endpoint That Returns 404

If you read EroLove's marketing pages, category landings, or even its /privacy policy, they repeatedly state that users can upload and send their own photos into the chat. Yet inside the chat UI, there is no attachment or upload button anywhere.

Looking at the 179 KB inline script explains why:

// Simplified from the inline client bundle
if (feat("userPhotos")) {
  renderUploadButton();
}

The client gates the upload button on a runtime feature flag fetched from GET /api/config. When we inspected the live response from /api/config on October 23, 2026, the enabled array was:

{
  "features": ["chatPhotos", "presence", "voice"]
}

"userPhotos" is omitted, so the UI cleanly hides the button. Meanwhile, the client bundle still contains the complete upload implementation: fileToJpegDataUrl(), sendPhoto(), the [userphoto] message parser, and all its error handlers.

To see whether the backend route was merely hidden or actually disabled, we sent a signed-in request directly to the endpoint named in sendPhoto():

POST /api/chat/upload HTTP/2
Host: erolove.ai

The server responded immediately:

HTTP 404 Not Found
{"error": "not_found"}

Why this matters

Shipping dark code behind a feature flag is normal. The bug is letting 30 static pages—including the privacy policy and machine-readable .md twins—describe data handling for a feature whose backend route returns 404. When your static SEO/GEO layer and your runtime /api/config flags have no shared source of truth, your documentation lies about your API surface.


2. Unconstrained Language Extraction in an LLM Memory Pipeline

The standout feature in EroLove—and the reason it scored well on conversation in our full API teardown and memory test—is its transparent memory panel. Unlike most companion apps that hide long-term memory in an opaque vector store, EroLove exposes GET /api/memory?companion=..., allowing users to read, edit, pin, delete, or export every extracted fact as HTML.

We tested memory extraction across three companions in an all-English session. Our account's default_language was "en", every companion metadata object had language: "en", and every prompt we sent was in English.

Then we inspected the extracted facts array returned by the memory API:

  • Companion 1 (Anna, persona set in Tallinn, Estonia): Every English fact we planted (a cat named Miso, a shellfish allergy, rent stress) was extracted and stored in Estonian.
  • Companion 2 (Rae): Extracted facts came back in French.
  • Companion 3 (Yuna): Returned facts: []—nothing extracted at all.

When we manually edited one of Anna's Estonian memory strings into English via the memory API, the next chat turn immediately reflected our edit. That isolated the bug: memory injection and editing work, but the background memory-extraction prompt does not pin the output language to the session's default_language. Instead, the extractor LLM appears to infer the output language from the character's biographical flavor text (e.g., "Tallinn" triggering Estonian) or drifts into another language entirely.

Takeaway for LLM engineers

If you run an asynchronous fact-extraction pass over chat history to populate a user-visible memory store, explicitly constrain the output language in the extraction system prompt and schema (e.g., "Write every fact in the user's session language: en"). Never assume an LLM will output English summaries just because the user's messages are in English—especially when the system prompt contains foreign proper nouns.


3. Collapsing Content-Policy Refusals and Queue Outages Into One UI String

During our image-generation test, we requested 5 photos across our session (10 Sparks per photo). Three succeeded in 16–23 seconds at 768x1024. Two failed.

In the browser UI, both failures displayed the exact same toast:

"Couldn't take a photo right now — try a bit later"

A normal user—or a black-box reviewer—reads that message as a transient server or GPU queue outage and clicks retry.

Look at the actual JSON payload returned by the polling endpoint, however, and you see something completely different:

{
  "status": "blocked",
  "charged": 0
}

And in the client bundle's switch statement, status === "blocked" (a content/moderation filter rejection) and photo_queue / timeout errors are routed to the exact same user-facing error string.

To EroLove's credit, the backend refunded the Sparks on both blocked attempts (charged: 0). But collapsing a deterministic policy refusal ("blocked") into a transient retry message ("try a bit later") trains users to keep hammering the generation queue with the same prompt.


4. Negative Caching on a Poll-Before-Ready Image CDN, Plus an "Unlimited" Cap

Two smaller findings from the same bundle inspection rounded out the audit:

  1. CDN caching 404s on newly generated media: Generated photos are served from /img/photos/{userId}/{photoId}.webp. When the client requests that URL before the worker finishes writing the WebP file, the edge returns 404. On one of our three successful renders, that initial 404 was cached, leaving the image broken until we appended a cache-busting query string. If your client polls or prefetches media URLs, never allow your CDN to cache 404 Not Found responses on deterministic asset paths without Cache-Control: no-store on the error response.
  2. "Unlimited messages" is dailyLimit: 800: While the pricing page advertises "Unlimited messages" on Premium, inspecting the authenticated user object (me.dailyLimit) shows a hard ceiling of 800 messages per day. In a 1-on-1 chat nobody will hit 800 turns, but in EroLove's multi-character rooms—where a single user prompt triggers multiple character replies—the math adds up much faster.

Summary & Reproducible Dataset

Black-box UI testing tells you whether an app feels good; reading the client bundle tells you why it broke.

We published our full October 2026 findings—covering all 10 tested AI companion apps, $136.89 in real card charges, 7 captured bank statement descriptors, and our 60-prompt benchmark—as an open CC BY 4.0 dataset:

來源:DEV Community · dev.to