Skip to content
Merged
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
2 changes: 1 addition & 1 deletion QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,5 +164,5 @@ python scripts/evaluate_poc.py --tool ralph

## Web dashboard

By default the server also serves a minimal green glass dashboard at `http://localhost:8005` for live status, memory, schedules, chat, integrations, and logs.
By default the server also serves a multilingual dashboard at `http://localhost:8005` with `English` as the default UI, optional `한국어 / 日本語 / 中文` switching, runtime diagnostics, memory/schedule/chat views, and live logs.
When the server binds to `0.0.0.0`, startup logs also print a LAN URL if one is detected.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,4 +380,4 @@ This project is licensed under the [GNU Affero General Public License v3.0](LICE

## Web dashboard

Open http://localhost:8005 for the minimal green glass dashboard that surfaces status, memory, schedules, chat, integrations, and live logs.
Open http://localhost:8005 for the multilingual dashboard with `English` as the default UI, optional `한국어 / 日本語 / 中文` switching, a diagnostics-first runtime view, editable memory/schedules/chat, and live logs.
13 changes: 13 additions & 0 deletions docs/AGENT_FEATURE_PLANNING.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,19 @@
- 검증:
- `python scripts/evaluate_poc.py --tool ralph`

### EPIC-I: Web Dashboard Redesign (PRD 5.1)
- [ ] (SA-0/SA-3, TODO) 대시보드 범위를 PRD/Planning에 반영하고 `English` 기본 UI + `한국어/日本語/中文` 전환 정책 확정
- [ ] (SA-3, TODO) `server/web/static/`를 HTML shell + 분리된 CSS/JS asset 구조로 재편
- [ ] (SA-3, TODO) 개요 화면을 짧은 제목/보조 본문 구조로 재설계하고 브로콜리 마스코트 이미지는 메인 hero 1곳에만 유지
- [ ] (SA-3, TODO) Diagnostics 탭을 추가해 Runtime Summary / STT / Connection / Integrations / Advanced 섹션으로 재구성
- [ ] (SA-3, TODO) STT `device`, `model_size`와 Connection `mode`만 안전한 편집 대상으로 제한한 curated config UI 추가
- [ ] (SA-3, TODO) `GET /api/diagnostics/`, `POST /api/diagnostics/check`와 대시보드 공유 runtime state를 추가
- [ ] (SA-1/SA-3, TODO) 정적 UI 테스트와 diagnostics API 테스트를 보강하고 Docker 진입점으로 회귀 검증
- 검증:
- `docker compose -f docker/docker-compose.test.yml run --rm server-test pytest server/tests/test_web_dashboard_static.py server/tests/test_web_runtime_routes.py`
- `docker compose -f docker/docker-compose.test.yml run --rm server-test pytest server/tests/test_runtime_controller.py server/tests/test_runtime_preferences.py`
- `docker compose -f docker/docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from server-test`

---

## 4) 실행 순서 (권장 스프린트)
Expand Down
5 changes: 5 additions & 0 deletions docs/PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@
- USB 직결 시 별도 Wi-Fi 설정 없이 자동 인식되는 no-config wired 경로 제공
- CLI에서 연결 모드 선택/검증 UX 제공
- 서버 설정과 펌웨어 설정 키 동기화 보장
- 웹 대시보드는 기본 언어를 `English`로 제공하고, `한국어`, `日本語`, `中文` UI 모드를 선택할 수 있어야 한다
- 웹 대시보드의 언어 전환은 UI 복사본과 날짜/숫자 포맷에만 적용하며, STT/TTS/에이전트 응답 언어 기본값은 자동 변경하지 않는다
- 웹 UI는 제목/본문 계층을 분리하고, 긴 문장은 본문/메타 슬롯으로 내려 다국어에서도 레이아웃이 무너지지 않도록 유지해야 한다
- 웹 대시보드는 STT/LLM/연결/Integration 상태를 진단하고 일부 안전한 설정만 수정할 수 있는 diagnostics-first UX를 제공해야 한다
- 웹 대시보드 브랜딩은 메인 화면의 브로콜리 마스코트 중심으로 단순화하고, 나머지 화면은 데이터 중심으로 구성해야 한다

## 5.2 기능 트랙 B — 통합(Integration) 확장
- Weather / Search / Calendar / Notify / Maps 기능 고도화
Expand Down
33 changes: 25 additions & 8 deletions server/tests/test_web_dashboard_static.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,30 @@
from pathlib import Path


def test_dashboard_minimizes_copy_with_css_rules():
html = (Path(__file__).resolve().parents[1] / "web" / "static" / "index.html").read_text(encoding="utf-8")
STATIC_DIR = Path(__file__).resolve().parents[1] / "web" / "static"

assert ".hero-grid>.panel{display:none}" in html
assert ".brand .muted,.top .muted,.hero-copy>div p:not(.eyebrow),.overview .hint,.status .meta,.nav button small,.metrics .row span:first-child{display:none}" in html
assert 'id="hero-headline"' in html
assert 'id="overview-schedules"' in html
assert 'id="chat-feed"' in html

def test_dashboard_shell_defaults_to_english_and_uses_split_assets():
html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")

assert '<html lang="en">' in html
assert 'href="/dashboard.css"' in html
assert 'src="/dashboard.js"' in html
assert 'id="locale-select"' in html
assert 'data-tab="diagnostics"' in html
assert 'id="runtime-summary-cards"' in html
assert 'id="config-json"' in html
assert 'id="logs-view"' in html
assert 'id="hero-headline"' in html
assert html.count('/brand-assets/ccoli.png') == 1


def test_dashboard_css_preserves_text_safe_layout_rules():
css = (STATIC_DIR / "dashboard.css").read_text(encoding="utf-8")
js = (STATIC_DIR / "dashboard.js").read_text(encoding="utf-8")

assert ".title-clamp{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}" in css
assert ".body-copy{font-size:.95rem;line-height:1.6;color:var(--muted)}" in css
assert "overflow-wrap:anywhere" in css
assert ".hero-figure" in css
assert 'localStorage.getItem("ccoli.locale") || "en"' in js
assert '"/api/diagnostics/"' in js
108 changes: 103 additions & 5 deletions server/tests/test_web_runtime_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,22 @@ def handle_text_command(self, text):
def status_snapshot(self):
return {
"llm_priority": ["ollama", "api", "ollama_cpu", "other"],
"api_priority": ["gemini", "claude", "chatgpt"],
"connection_priority": ["wired", "wifi"],
"processor_priority": ["gpu", "cpu"],
"audio_runtime_notes": ["Current TTS backend is Edge TTS."],
"stt": {
"configured_devices": ["cpu"],
"device_in_use": "cpu",
},
"llm": {
"configured_provider": "gemini",
"active_provider": "gemini",
"active_model": "gemini-2.5-flash",
"active_bucket": "api",
"active_processor": "cpu",
"last_error_code": None,
},
}


Expand All @@ -50,15 +64,32 @@ def generate_response(self, text, speaker_id="web_user"):
return "일반 응답", "none"


def _client():
def _client(dashboard_state=None):
configure("")
agent = _FakeAgent()
app = create_app(lambda: agent, lambda: None, lambda: "agent")
return TestClient(app), agent
state = dashboard_state or {
"warmup": {
"stt_ready": True,
"tts_ready": True,
"updated_at": "2026-03-24T01:00:00+00:00",
},
"connection": {
"connected": True,
"current_transport": "wired",
"current_endpoint": "/dev/cu.usbserial-test",
"last_transport": "wired",
"last_endpoint": "/dev/cu.usbserial-test",
"last_connected_at": "2026-03-24T01:01:00+00:00",
"last_disconnected_at": None,
},
"last_checks": {},
}
app = create_app(lambda: agent, lambda: None, lambda: "agent", lambda: state)
return TestClient(app), agent, state


def test_api_chat_handles_runtime_priority_command():
client, agent = _client()
client, agent, _state = _client()

response = client.post("/api/chat/", json={"text": "우선순위 상태"})

Expand All @@ -69,11 +100,78 @@ def test_api_chat_handles_runtime_priority_command():


def test_api_status_exposes_runtime_snapshot():
client, _agent = _client()
client, _agent, _state = _client()

response = client.get("/api/status")

assert response.status_code == 200
payload = response.json()
assert payload["runtime"]["llm_priority"] == ["ollama", "api", "ollama_cpu", "other"]
assert payload["runtime"]["connection_priority"] == ["wired", "wifi"]


def test_api_diagnostics_exposes_normalized_runtime_data():
client, _agent, _state = _client()

response = client.get("/api/diagnostics/")

assert response.status_code == 200
payload = response.json()
assert payload["mode"] == "agent"
assert payload["runtime"]["warmup"]["stt_ready"] is True
assert payload["runtime"]["llm"]["active_provider"] == "gemini"
assert payload["stt"]["model_size"] == "medium"
assert payload["stt"]["device_in_use"] == "cpu"
assert payload["connection"]["connected"] is True
assert payload["connection"]["current_transport"] == "wired"


def test_api_diagnostics_check_records_results_and_reflects_connection_changes():
dashboard_state = {
"warmup": {"stt_ready": True, "tts_ready": False, "updated_at": "2026-03-24T01:00:00+00:00"},
"connection": {
"connected": True,
"current_transport": "wired",
"current_endpoint": "/dev/cu.usbserial-test",
"last_transport": "wired",
"last_endpoint": "/dev/cu.usbserial-test",
"last_connected_at": "2026-03-24T01:01:00+00:00",
"last_disconnected_at": None,
},
"last_checks": {},
}
client, _agent, state = _client(dashboard_state)

initial = client.post("/api/diagnostics/check", json={"target": "connection"})
assert initial.status_code == 200
assert initial.json()["ok"] is True
assert state["last_checks"]["connection"]["ok"] is True

state["connection"].update(
{
"connected": False,
"current_transport": None,
"current_endpoint": None,
"last_disconnected_at": "2026-03-24T01:02:00+00:00",
}
)

changed = client.post("/api/diagnostics/check", json={"target": "connection"})
assert changed.status_code == 200
assert changed.json()["ok"] is False
assert "No active device connection" in changed.json()["summary"]


def test_api_diagnostics_check_supports_stt_and_llm_targets():
client, _agent, state = _client()

stt_result = client.post("/api/diagnostics/check", json={"target": "stt"})
llm_result = client.post("/api/diagnostics/check", json={"target": "llm"})

assert stt_result.status_code == 200
assert stt_result.json()["target"] == "stt"
assert stt_result.json()["ok"] is True
assert llm_result.status_code == 200
assert llm_result.json()["target"] == "llm"
assert llm_result.json()["ok"] is True
assert set(state["last_checks"].keys()) >= {"stt", "llm"}
21 changes: 17 additions & 4 deletions server/web/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
_agent_ref: Callable = lambda: None
_robot_ref: Callable = lambda: None
_mode_ref: Callable = lambda: "agent"
_default_dashboard_state: dict = {}
_dashboard_state_ref: Callable = lambda: _default_dashboard_state

# ── WebSocket client registry ─────────────────────────────────────────────────
_ws_clients: Set[WebSocket] = set()
Expand All @@ -40,6 +42,11 @@ def get_mode() -> str:
return _mode_ref()


def get_dashboard_state() -> dict:
state = _dashboard_state_ref()
return state if isinstance(state, dict) else {}


async def broadcast(event: dict):
"""Fan-out a JSON event to all connected WebSocket clients."""
msg = json.dumps(event, ensure_ascii=False, default=str)
Expand Down Expand Up @@ -101,24 +108,30 @@ async def _status_poller():
pass


def create_app(agent_fn: Callable, robot_fn: Callable, mode_fn: Callable) -> FastAPI:
global _agent_ref, _robot_ref, _mode_ref
def create_app(
agent_fn: Callable,
robot_fn: Callable,
mode_fn: Callable,
dashboard_state_fn: Callable | None = None,
) -> FastAPI:
global _agent_ref, _robot_ref, _mode_ref, _dashboard_state_ref
_agent_ref = agent_fn
_robot_ref = robot_fn
_mode_ref = mode_fn
_dashboard_state_ref = dashboard_state_fn or (lambda: _default_dashboard_state)

app = FastAPI(title="ccoli dashboard", docs_url="/api/docs", lifespan=lifespan)

# Mount REST routers
from .routes import (
api_status, api_memory, api_conversation,
api_schedules, api_config, api_integrations,
api_chat, api_logs,
api_chat, api_logs, api_diagnostics,
)
for mod in (
api_status, api_memory, api_conversation,
api_schedules, api_config, api_integrations,
api_chat, api_logs,
api_chat, api_logs, api_diagnostics,
):
app.include_router(mod.router)

Expand Down
Loading
Loading