Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/space-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Space demo check

on:
pull_request:
paths:
- spaces/domux-demo/**
- .github/workflows/space-check.yml
- .github/workflows/sync-space.yml
push:
branches:
- main
paths:
- spaces/domux-demo/**

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install test dependencies
# The model backend is not loaded here, so torch/transformers are not needed.
run: pip install pytest requests "gradio==6.28.0"
- name: Test parsing, the endpoint backend and the Gradio app
env:
DOMUX_API_BASE: http://127.0.0.1:9/v1
run: pytest -q spaces/domux-demo/tests
52 changes: 52 additions & 0 deletions .github/workflows/sync-space.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Sync Hugging Face Space

on:
push:
branches:
- main
paths:
- spaces/domux-demo/**
workflow_dispatch:

permissions:
contents: read

concurrency:
group: sync-space
cancel-in-progress: true

jobs:
sync:
runs-on: ubuntu-latest
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_SPACE_ID: ${{ vars.HF_SPACE_ID || 'iFlytekOpenSource/Domux-Demo' }}
steps:
- name: Skip when HF_TOKEN is not configured
if: env.HF_TOKEN == ''
run: echo "::notice::HF_TOKEN secret is not set; skipping the Space sync."
- uses: actions/checkout@v6
if: env.HF_TOKEN != ''
- uses: actions/setup-python@v5
if: env.HF_TOKEN != ''
with:
python-version: '3.12'
- name: Upload spaces/domux-demo to the Space
if: env.HF_TOKEN != ''
run: |
pip install --quiet "huggingface_hub>=1.0"
python - <<'PY'
import os
from huggingface_hub import HfApi

api = HfApi(token=os.environ["HF_TOKEN"])
api.upload_folder(
repo_id=os.environ["HF_SPACE_ID"],
repo_type="space",
folder_path="spaces/domux-demo",
ignore_patterns=["tests/**", "__pycache__/**"],
delete_patterns=["*.py", "requirements.txt", "README.md"],
commit_message=f"Sync from iflytek/domux@{os.environ['GITHUB_SHA'][:7]}",
)
print(f"Synced to https://huggingface.co/spaces/{os.environ['HF_SPACE_ID']}")
PY
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- Gradio demo for Hugging Face Spaces (`spaces/domux-demo`), with a local
ZeroGPU backend or an OpenAI-compatible endpoint backend, and a workflow that
syncs it to the Space

## [0.1.0] - 2026-06-30

First public release of Domux (`Domux-Gemma-4-E2B-it`), a lightweight,
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ As an early exploration, Domux is still evolving. We're focusing on three direct

## 🎬 Demo

> 🖥️ **Try it in the browser:** [`spaces/domux-demo`](spaces/domux-demo) is a Gradio demo that runs as a Hugging Face Space (ZeroGPU) or locally, and can also front your own vLLM/SGLang endpoint.

The model outputs pipe-delimited slots with 7 fields:

```
Expand Down
2 changes: 2 additions & 0 deletions README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ Domux 基于具备泛化能力的基座模型,**不依赖固定的设备白名

## 🎬 示例演示

> 🖥️ **在线体验:** [`spaces/domux-demo`](spaces/domux-demo) 提供 Gradio 演示,可部署为 Hugging Face Space(ZeroGPU)或在本地运行,也可以接入你自己的 vLLM/SGLang 服务。

模型输出包含 7 个字段的竖线分隔槽位:

```
Expand Down
62 changes: 62 additions & 0 deletions spaces/domux-demo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
---
title: Domux Demo
emoji: 🏠
colorFrom: blue
colorTo: indigo
sdk: gradio
sdk_version: 6.28.0
python_version: "3.12"
app_file: app.py
pinned: false
license: apache-2.0
short_description: Smart-home commands to structured slots with Domux
models:
- iFlytekOpenSource/Domux
suggested_hardware: zero-a10g
---

# Domux Demo Space

A Gradio demo for [Domux](https://github.com/iflytek/domux). Type a smart-home command and the demo shows the raw model output, the parsed `action|device|attribute|value|unit|room|floor` slots, whether every line has the 7 fields, and the latency.

This directory is the source of the Hugging Face Space. `.github/workflows/sync-space.yml` uploads it to the Space whenever it changes on `main`.

## Backends

| Mode | When | How it runs |
| --- | --- | --- |
| Local model (default) | `DOMUX_API_BASE` is not set | Loads `iFlytekOpenSource/Domux` with transformers in BF16, pinned to the revision used by the full 4,057-sample evaluation (`cases/domux-4057-full-eval`). On a ZeroGPU Space each request gets a GPU through `@spaces.GPU`. |
| OpenAI-compatible endpoint | `DOMUX_API_BASE` is set | Sends the command to a vLLM or SGLang server (see the main README's Deployment section). The Space itself can then run on free CPU hardware. |

Environment variables (Space **Settings → Variables and secrets**):

| Variable | Default | Description |
| --- | --- | --- |
| `DOMUX_MODEL_ID` | `iFlytekOpenSource/Domux` | Model repo for the local backend |
| `DOMUX_REVISION` | `6c71a32f…` | Model revision for the local backend |
| `DOMUX_API_BASE` | | OpenAI-compatible base URL, e.g. `https://host:8000/v1`. Switches to the endpoint backend |
| `DOMUX_API_MODEL` | `domux` | Served model name on that endpoint |
| `DOMUX_API_KEY` | | Bearer token for that endpoint (store as a secret) |

The model needs about 10 GB for BF16 weights, so the local backend needs a GPU Space (ZeroGPU or a dedicated GPU). It will start on CPU hardware, but each request takes a long time.

## Run locally

```bash
cd spaces/domux-demo
pip install gradio==6.28.0 -r requirements.txt
python app.py # local model; needs a GPU with 20 GB+ VRAM
DOMUX_API_BASE=http://localhost:8000/v1 python app.py # or point at a running vLLM/SGLang server
```

Open http://127.0.0.1:7860.

## Set up the Space (maintainers)

1. Create a Gradio Space, e.g. `iFlytekOpenSource/Domux-Demo`, and select ZeroGPU hardware (or leave it on CPU and set `DOMUX_API_BASE`).
2. In this GitHub repository add a secret `HF_TOKEN` (a token with write access to the Space) and, if the Space id differs from `iFlytekOpenSource/Domux-Demo`, a variable `HF_SPACE_ID`.
3. Run **Sync Hugging Face Space** from the Actions tab once. Later changes to `spaces/domux-demo/` on `main` sync automatically.

## License

The demo code is Apache-2.0. The Domux model weights are a Gemma derivative governed by the [Gemma Terms of Use](https://ai.google.dev/gemma/terms); see [NOTICE](https://github.com/iflytek/domux/blob/main/NOTICE).
83 changes: 83 additions & 0 deletions spaces/domux-demo/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""Gradio demo for Domux: natural-language smart-home commands to structured slots."""

try:
# Must be imported before torch on ZeroGPU Spaces; optional everywhere else.
import spaces
except ImportError:
spaces = None

import gradio as gr

from domux_backend import FIELDS, backend_from_env, run

EXAMPLES = [
"Turn on the living room light",
"Set bedroom AC to 22 degrees",
"dim the spots in the living room",
"set the curtain in the majlis on the ground floor to 65 percent",
"turn on the ceiling light and open the curtain a bit in bedroom 1",
"Turn off all lights in the Living Room on the Ground Floor, set the AC to Cool mode at "
"24 degrees in the Guest Bedroom, and open the curtains halfway in the Dining Room.",
"Turn on the Master Light in the Master Bedroom on the Second Floor, set brightness to 80%, "
"color temperature to 4000K, color to Blue, and mode to Reading.",
"Activate romantic mode",
]

DESCRIPTION = """\
# Domux · Smart-home command understanding

[Domux](https://github.com/iflytek/domux) turns a natural-language smart-home command into
pipe-delimited slots: `action|device|attribute|value|unit|room|floor`, one line per action,
`*` for fields that don't apply. It is fine-tuned from Gemma-4-E2B-it and trained on English
commands for lights, AC, curtains/blinds and scene modes.

[Model](https://huggingface.co/iFlytekOpenSource/Domux) ·
[Output spec](https://github.com/iflytek/domux/blob/main/docs/output-spec.md) ·
[Benchmark](https://github.com/iflytek/domux#-benchmark)
"""

backend = backend_from_env()
generate = backend.generate
if spaces is not None and backend.__class__.__name__ == "LocalBackend":
generate = spaces.GPU(duration=30)(generate)


def parse(query: str):
try:
result = run(generate, query)
except ValueError as exc:
raise gr.Error(str(exc)) from exc
status = (
f"{'✅ Valid' if result.format_ok else '⚠️ Not 7-field'} format · "
f"{len(result.rows)} action(s) · {result.latency_ms:.0f} ms"
)
return result.raw, result.rows, status


with gr.Blocks(title="Domux Demo") as demo:
gr.Markdown(DESCRIPTION)
with gr.Row():
with gr.Column():
query = gr.Textbox(
label="Command",
placeholder="e.g. Set the bedroom AC to 24 degrees and close the curtains",
lines=3,
max_length=1000,
)
submit = gr.Button("Parse", variant="primary")
gr.Examples(examples=EXAMPLES, inputs=query)
with gr.Column():
status = gr.Markdown()
slots = gr.Dataframe(headers=FIELDS, label="Slots", interactive=False, wrap=True)
raw = gr.Code(label="Raw model output", language=None)
gr.Markdown(
f"Backend: `{backend.label}`. Latency shown here includes Space queueing and GPU "
"allocation, so it is higher than the benchmark numbers."
)

submit.click(parse, inputs=query, outputs=[raw, slots, status])
query.submit(parse, inputs=query, outputs=[raw, slots, status])


if __name__ == "__main__":
demo.queue(max_size=32).launch()
134 changes: 134 additions & 0 deletions spaces/domux-demo/domux_backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""Inference backends and slot parsing for the Domux demo Space."""

import os
import time
from dataclasses import dataclass

FIELDS = ["action", "device", "attribute", "value", "unit", "room", "floor"]
MAX_NEW_TOKENS = 256
MAX_QUERY_CHARS = 1000

# Same model revision as the full 4,057-sample evaluation in cases/domux-4057-full-eval.
DEFAULT_MODEL_ID = "iFlytekOpenSource/Domux"
DEFAULT_REVISION = "6c71a32f4d624cadfd9fce9d10240d8068e53456"


@dataclass
class ParseResult:
raw: str
rows: list[list[str]]
format_ok: bool
latency_ms: float


def split_slots(output: str) -> tuple[list[list[str]], bool]:
"""Split model output into 7-field rows.

Lines are separated by newlines (or `&`, as in eval/run_eval.py). The output is
format-compliant only when every non-empty line has exactly seven fields.
"""
rows: list[list[str]] = []
format_ok = bool(output.strip())
for line in output.replace("&", "\n").split("\n"):
line = line.strip()
if not line:
continue
fields = [field.strip() for field in line.split("|")]
if len(fields) != len(FIELDS):
format_ok = False
fields = (fields + [""] * len(FIELDS))[: len(FIELDS)]
rows.append(fields)
return rows, format_ok


class LocalBackend:
"""Runs the model with transformers, as in cases/domux-4057-full-eval/eval_direct.py."""

def __init__(self, model_id: str, revision: str) -> None:
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

self._torch = torch
self.device = "cuda" if torch.cuda.is_available() or _on_zero_gpu() else "cpu"
self.tokenizer = AutoTokenizer.from_pretrained(model_id, revision=revision)
self.model = AutoModelForCausalLM.from_pretrained(
model_id, revision=revision, dtype=torch.bfloat16
).to(self.device)
self.model.eval()
self.label = f"{model_id}@{revision[:8]} (transformers, {self.device})"

def generate(self, query: str) -> str:
text = self.tokenizer.apply_chat_template(
[{"role": "user", "content": query}], add_generation_prompt=True, tokenize=False
)
inputs = self.tokenizer(text, return_tensors="pt").to(self.model.device)
with self._torch.inference_mode():
output = self.model.generate(
**inputs,
max_new_tokens=MAX_NEW_TOKENS,
do_sample=False,
pad_token_id=self.tokenizer.pad_token_id or self.tokenizer.eos_token_id,
)
new_tokens = output[0][inputs["input_ids"].shape[1] :]
return self.tokenizer.decode(new_tokens, skip_special_tokens=True).strip()


class OpenAIBackend:
"""Calls an OpenAI-compatible endpoint (vLLM / SGLang) serving Domux."""

def __init__(self, base_url: str, model: str, api_key: str = "", timeout: float = 30) -> None:
import requests

self._session = requests.Session()
self.url = f"{base_url.rstrip('/')}/chat/completions"
self.model = model
self.headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
self.timeout = timeout
self.label = f"{model} (OpenAI-compatible endpoint)"

def generate(self, query: str) -> str:
response = self._session.post(
self.url,
headers=self.headers,
json={
"model": self.model,
"messages": [{"role": "user", "content": query}],
"temperature": 0.0,
"max_tokens": MAX_NEW_TOKENS,
},
timeout=self.timeout,
)
response.raise_for_status()
return str(response.json()["choices"][0]["message"]["content"]).strip()


def backend_from_env() -> "LocalBackend | OpenAIBackend":
"""Use DOMUX_API_BASE when set, otherwise load the model in-process."""
api_base = os.getenv("DOMUX_API_BASE", "").strip()
if api_base:
return OpenAIBackend(
api_base,
os.getenv("DOMUX_API_MODEL", "domux").strip() or "domux",
os.getenv("DOMUX_API_KEY", "").strip(),
)
return LocalBackend(
os.getenv("DOMUX_MODEL_ID", DEFAULT_MODEL_ID).strip() or DEFAULT_MODEL_ID,
os.getenv("DOMUX_REVISION", DEFAULT_REVISION).strip() or DEFAULT_REVISION,
)


def run(generate, query: str) -> ParseResult:
query = query.strip()
if not query:
raise ValueError("Enter a smart-home command.")
if len(query) > MAX_QUERY_CHARS:
raise ValueError(f"Commands are limited to {MAX_QUERY_CHARS} characters.")
started = time.perf_counter()
raw = generate(query)
latency_ms = (time.perf_counter() - started) * 1000
rows, format_ok = split_slots(raw)
return ParseResult(raw=raw, rows=rows, format_ok=format_ok, latency_ms=latency_ms)


def _on_zero_gpu() -> bool:
return os.getenv("SPACES_ZERO_GPU", "").lower() in {"1", "true"}
Loading
Loading