A Calorie-Counting HarmonyOS App That Keeps the LLM on a Short Leash
Disclaimer: The "Slim & Beautiful" app described in this article is a personal demo project. The calorie estimations, dietary, and exercise suggestions mentioned are all generated by an AI model and are for daily reference only. They do not constitute any medical, nutritional, or diagnostic advice.
On July 15th this year, the first day of the hottest summer period, I stepped on the scale in the morning: 67.5 kg, nearly 2 kg heavier than at the start of the year. An old saying goes that losing weight during these dog days is twice as effective with half the effort. Regardless of the science, I happened to have two things on hand: an M1 Mac with DevEco Studio installed, and a Lanyun Yuanshengdai MaaS account I had tested recently and liked. When I was picking a third-party large model API for my dev tools, I tested Lanyun's DeepSeek-V3.2 from connectivity all the way to cache hits. All five TTFT measurements were tightly packed between 174 and 194 milliseconds, and the cache hit rate for repeated large contexts was 97.9% (self-tested data, run directly via curl). After the test, I wanted to use it in a real project. The weight loss idea came up, and the two clicked perfectly.
Thus, this app was born: Slim & Beautiful. You speak a sentence into the input box like "Today 67.5 kg, breakfast oatmeal and boiled egg." It logs the weight and breakfast separately, estimates the calories for each item, and pushes the top calorie budget progress bar forward. The client side is a three-tab page written in ArkUI. The model uses Lanyun's DeepSeek-V3.2, with a local Python gateway sitting in between.
Where to Put the Key: Decided First
A rule was set from the start regarding the structure: Lanyun's API Key is only placed in the gateway's environment variables; not a single character appears in the ArkTS code. The client side only talks to the gateway at 127.0.0.1:18090, and the gateway uses the Key to call Lanyun's interface. This way, the client-side code can be screenshotted and shared freely without worrying about accidentally exposing the Key in every image.
export LY_API_KEY="Your Lanyun Key"
export LY_BASE_URL="https://maas-api.lanyun.net/v1"
export LY_MODEL="/maas/deepseek-ai/DeepSeek-V3.2"
python3 slim_health_gateway.py
When the startup output shows LLM configured: True, the model side is ready.
There are two easy pitfalls when connecting to Lanyun, both of which I stepped into myself: First, the model name includes a /maas/ prefix. /maas/deepseek-ai/DeepSeek-V3.2 is the complete call name; writing deepseek-chat directly results in a 404. Second, the Base URL already includes /v1. If you append /v1/chat/completions in your code, it becomes /v1/v1/..., causing a 405 error. These two pitfalls are actually clearly written in the API example on the console's model details page. Copying from there prevents mistakes.
The model hub lists DeepSeek, Qwen, GLM, Kimi, and others, all callable with a single Key. Choosing DeepSeek-V3.2 this time was a no-brainer; I had previously tested its performance on this link, and the latency window was as narrow as a straight line, more than enough for a simple Q&A check-in task. If you don't have an account yet, you can register from this entrance to get a Key from the console: Lanyun Yuanshengdai Registration Link.
The Model Estimates, the Gateway Records
The division of labor is what I insisted on most in this project: The large model handles fuzzy reasoning; the code handles precise calculations.
When a user says, "I had a bowl of oatmeal, a boiled egg, and a cup of unsweetened soy milk for breakfast," no one reports the calorie count for each item. Only the model can do this kind of estimation. The prompt sent by the gateway to DeepSeek defines an action structure, requiring it to output only JSON:
{
"actions": [
{"action": "log_weight", "weightKg": 67.5},
{"action": "log_meal", "meal": "Breakfast",
"items": [{"name": "Oatmeal", "kcal": 150, "note": "High carb"}]}
],
"reply": "Got it, here's a confirmation"
}
The model's output stops here. Writing to disk in slim_journal.json, aggregating the day's intake, comparing against the 1650 kcal target budget, and calculating weight trends are all done by plain Python code in the gateway. Numbers do not undergo secondary processing by the model. The line "Today's intake: 750 kcal" in the daily report is added up item by item from the journal file; the model has no chance to fabricate it.
The prompt also includes a rule: when a user proposes an extreme dieting goal, no logging actions are generated, only dissuasion. This rule was indeed triggered later.
Running in the App
Opening DevEco's Previewer, the app's home screen shows a status capsule at the top. It auto-detects the gateway on startup; a green dot lights up with the text "Model Online." Below are three stats: Intake, Exercise Burned, Recent Weight, plus a calorie budget progress bar.
The first sentence was the one from the beginning, packing two things into one sentence. After sending, two cards pop up below the reply: a weight check-in card and a breakfast log card. In the breakfast card, the estimated calories and nutritional notes for oatmeal, boiled egg, and unsweetened soy milk are listed line by line, with the meal total at the bottom. The top "Recent Weight" simultaneously updates to 67.5 kg, and the progress bar moves. A small line at the bottom of the card reads "Parsed by Large Model"—this line will be useful later.
At noon, a chicken breast salad and a latte. The "Today's Intake" line at the bottom of the lunch card automatically added breakfast. This accumulation happens on the journal file on the gateway side and has nothing to do with the model's conversation memory. Even if you kill the app and restart, the ledger remains.
In the evening, a 40-minute brisk walk. The exercise card estimates a 160 kcal burn, and the top "Remaining Intake" rises back up a notch.
The Daily Report Page Didn't Update, Uncovering an @Builder Pitfall
A strange thing happened when writing the daily report page: the top stats clearly showed 750 kcal intake and 160 kcal burned, but switching to the daily report tab, the cards still showed three zeros. Same data, same page, half of it was alive, half was dead.
Investigation pointed to the parameter-passing semantics of ArkUI's @Builder. I had extracted the card rendering into a parameterized @Builder, writing this.CardView(this.reportCard) in the daily report page. The parameter is passed by value, creating a snapshot copy at the first build. Later, when @State reportCard updated, the Builder's internal copy remained the old one. The top three numbers directly read this.intakeKcal, following normal state binding, so they were alive.
The fix was to not use a parameterized Builder for the daily report page and instead read the state directly in the tab for rendering:
Text(this.reportCard.title)
ForEach(this.reportCard.lines, (line: string) => {
Text(line)
}, (line: string, index: number) => String(index) + '-' + line)
After the change, switching tabs showed the daily report card matching the top numbers. The historical cards in the chat stream were intentionally left with pass-by-value parameters, as those are snapshots of each message at the time and shouldn't change with subsequent states. The same semantic was a pitfall in one place and exactly the desired behavior in another.
The trend page draws each weight record as a green bar. After adding a 67.2 kg record, the bottom shows "Change from earliest record: -0.3 kg." Only a week into the dog days, this number doesn't show much yet, but it's recorded.
Two Boundaries: One in the Prompt, One in the Code
I tested its limits by inputting, "I want to eat only one meal a day and lose 2.5 kg in a week, help me arrange it." The reply was a dissuasion with an alternative suggestion: one meal a day can easily lead to unstable blood sugar and rebound binge eating; losing 2.5 kg in a week mostly sheds water and muscle, suggesting a gentler pace. The key point is in the card area: Not a single log card was generated, and the top numbers didn't budge. The prompt rule "no logging actions for extreme dieting" truly held the line.
The other boundary is in the code. Stop the gateway, run unset LY_API_KEY and restart it. LLM configured: False appears, and the app's top capsule turns into an orange dot "Fallback Mode." Now, inputting "Now 67.0 kg" still logs the weight, because the gateway has a regex fallback layer. A fixed format like weight doesn't need the model.
Then inputting "I had two fried skewers and a milk tea for dinner," the reply is "The large model is temporarily unavailable, I can't log this sentence." Calorie estimation is something regex can't do. The fallback layer honestly states what it can't do, instead of pretending to log successfully. The small print "Parsed by Large Model" at the bottom of previous cards now changes to "Parsed by Rule Fallback," letting the user see the nature of this reply.
After testing, re-export the Key, and the green dot lights up again.
See You at the End of the Dog Days
This app's daily job now is to receive a weight in the morning, a few words for three meals, and give me an aggregated ledger on the daily report page at night. The cost side was also roughly calculated: DeepSeek-V3.2's listed price on Lanyun is 2 RMB per million input tokens (platform pricing page). A check-in sentence's request body is only a few hundred tokens, so a few dozen calls a day cost less than a cent. The stable latency curve tested before translates in-app to a feeling of tapping send and the card popping up quickly, with no moments of spinning circles making you doubt your life.
A note on the unfinished parts: The calorie estimation for meals currently fully trusts the model's common sense, without connecting to a food database for calibration. The same bowl of oatmeal might be estimated with a difference of tens of kcal between two times. It's sufficient for check-ins, but not for someone strictly controlling calories. The dog days end on August 23rd. By then, that row of green bars on the trend page will give a forty-day answer. This is a shared KPI for me, this app, and the Lanyun account behind it.