diff --git a/QUICKSTART.md b/QUICKSTART.md
index d7a860a..25a493e 100644
--- a/QUICKSTART.md
+++ b/QUICKSTART.md
@@ -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.
diff --git a/README.md b/README.md
index 451f0c7..fb4071d 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/docs/AGENT_FEATURE_PLANNING.md b/docs/AGENT_FEATURE_PLANNING.md
index 8cc0dbe..f9caf35 100644
--- a/docs/AGENT_FEATURE_PLANNING.md
+++ b/docs/AGENT_FEATURE_PLANNING.md
@@ -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) 실행 순서 (권장 스프린트)
diff --git a/docs/PRD.md b/docs/PRD.md
index 0d27059..27896ce 100644
--- a/docs/PRD.md
+++ b/docs/PRD.md
@@ -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 기능 고도화
diff --git a/server/tests/test_web_dashboard_static.py b/server/tests/test_web_dashboard_static.py
index 3ea1c9f..11fe158 100644
--- a/server/tests/test_web_dashboard_static.py
+++ b/server/tests/test_web_dashboard_static.py
@@ -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 '' 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
diff --git a/server/tests/test_web_runtime_routes.py b/server/tests/test_web_runtime_routes.py
index b77d6b7..947f8e0 100644
--- a/server/tests/test_web_runtime_routes.py
+++ b/server/tests/test_web_runtime_routes.py
@@ -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,
+ },
}
@@ -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": "우선순위 상태"})
@@ -69,7 +100,7 @@ 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")
@@ -77,3 +108,70 @@ def test_api_status_exposes_runtime_snapshot():
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"}
diff --git a/server/web/app.py b/server/web/app.py
index ad41ba4..e52aacc 100644
--- a/server/web/app.py
+++ b/server/web/app.py
@@ -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()
@@ -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)
@@ -101,11 +108,17 @@ 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)
@@ -113,12 +126,12 @@ def create_app(agent_fn: Callable, robot_fn: Callable, mode_fn: Callable) -> Fas
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)
diff --git a/server/web/routes/api_diagnostics.py b/server/web/routes/api_diagnostics.py
new file mode 100644
index 0000000..013b91c
--- /dev/null
+++ b/server/web/routes/api_diagnostics.py
@@ -0,0 +1,172 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import Literal
+
+from fastapi import APIRouter, Depends
+from pydantic import BaseModel
+
+from config_loader import get_config
+
+from ..app import get_agent, get_dashboard_state, get_mode
+from ..auth import require_auth
+
+router = APIRouter(prefix="/api/diagnostics", dependencies=[Depends(require_auth)])
+
+
+class DiagnosticsCheckRequest(BaseModel):
+ target: Literal["stt", "connection", "llm"]
+
+
+def _checked_at() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def _runtime_snapshot() -> dict:
+ agent = get_agent()
+ runtime_controller = getattr(agent, "runtime_controller", None)
+ if runtime_controller is None:
+ return {}
+ return runtime_controller.status_snapshot()
+
+
+def _stt_summary(config, runtime: dict) -> dict:
+ stt_runtime = runtime.get("stt", {}) if isinstance(runtime.get("stt"), dict) else {}
+ return {
+ "model_size": config.get("stt", "model_size", default="medium"),
+ "configured_device": config.get("stt", "device", default="cpu"),
+ "resolved_devices": list(stt_runtime.get("configured_devices", [])),
+ "device_in_use": stt_runtime.get("device_in_use"),
+ "language": config.get("stt", "language", default="ko"),
+ "notes": list(runtime.get("audio_runtime_notes", [])),
+ }
+
+
+def _connection_summary(config, runtime: dict, dashboard_state: dict) -> dict:
+ connection_state = (
+ dashboard_state.get("connection", {})
+ if isinstance(dashboard_state.get("connection"), dict)
+ else {}
+ )
+ return {
+ "mode": config.get("connection", "mode", default="auto"),
+ "priority": list(runtime.get("connection_priority", [])),
+ "connected": bool(connection_state.get("connected", False)),
+ "current_transport": connection_state.get("current_transport"),
+ "current_endpoint": connection_state.get("current_endpoint"),
+ "last_transport": connection_state.get("last_transport"),
+ "last_endpoint": connection_state.get("last_endpoint"),
+ "last_connected_at": connection_state.get("last_connected_at"),
+ "last_disconnected_at": connection_state.get("last_disconnected_at"),
+ }
+
+
+def _llm_summary(config, runtime: dict) -> dict:
+ llm_runtime = runtime.get("llm", {}) if isinstance(runtime.get("llm"), dict) else {}
+ llm_cfg = config.get_llm_config()
+ return {
+ "configured_provider": llm_cfg.get("provider", "ollama"),
+ "configured_model": llm_cfg.get("model", ""),
+ "priority": list(runtime.get("llm_priority", [])),
+ "api_priority": list(runtime.get("api_priority", [])),
+ "active_provider": llm_runtime.get("active_provider"),
+ "active_model": llm_runtime.get("active_model"),
+ "active_bucket": llm_runtime.get("active_bucket"),
+ "active_processor": llm_runtime.get("active_processor"),
+ "last_error_code": llm_runtime.get("last_error_code"),
+ }
+
+
+def _diagnostics_payload() -> dict:
+ config = get_config()
+ runtime = _runtime_snapshot()
+ dashboard_state = get_dashboard_state()
+ warmup = dashboard_state.get("warmup", {}) if isinstance(dashboard_state.get("warmup"), dict) else {}
+ last_checks = (
+ dashboard_state.get("last_checks", {})
+ if isinstance(dashboard_state.get("last_checks"), dict)
+ else {}
+ )
+
+ return {
+ "mode": get_mode(),
+ "runtime": {
+ "warmup": warmup,
+ "llm": _llm_summary(config, runtime),
+ },
+ "stt": _stt_summary(config, runtime),
+ "connection": _connection_summary(config, runtime, dashboard_state),
+ "last_checks": last_checks,
+ }
+
+
+def _build_check_result(target: str, diagnostics: dict) -> dict:
+ checked_at = _checked_at()
+
+ if target == "stt":
+ stt = diagnostics["stt"]
+ ok = bool(stt.get("configured_device")) and bool(stt.get("resolved_devices"))
+ active = stt.get("device_in_use")
+ summary = (
+ f"STT active on {active}"
+ if active
+ else f"STT configured for {stt.get('configured_device')}"
+ )
+ return {
+ "target": target,
+ "ok": ok,
+ "summary": summary,
+ "details": stt,
+ "checked_at": checked_at,
+ }
+
+ if target == "connection":
+ connection = diagnostics["connection"]
+ ok = bool(connection.get("connected"))
+ transport = connection.get("current_transport") or connection.get("last_transport")
+ endpoint = connection.get("current_endpoint") or connection.get("last_endpoint")
+ summary = (
+ f"Connected via {transport} ({endpoint})"
+ if ok and transport and endpoint
+ else "No active device connection"
+ )
+ return {
+ "target": target,
+ "ok": ok,
+ "summary": summary,
+ "details": connection,
+ "checked_at": checked_at,
+ }
+
+ llm = diagnostics["runtime"]["llm"]
+ last_error = llm.get("last_error_code")
+ ok = not bool(last_error)
+ active_provider = llm.get("active_provider") or llm.get("configured_provider")
+ active_model = llm.get("active_model") or llm.get("configured_model")
+ summary = (
+ f"LLM ready: {active_provider} / {active_model}"
+ if ok and active_provider
+ else f"LLM reported an error: {last_error or 'unknown'}"
+ )
+ return {
+ "target": target,
+ "ok": ok,
+ "summary": summary,
+ "details": llm,
+ "checked_at": checked_at,
+ }
+
+
+@router.get("/")
+def get_diagnostics():
+ return _diagnostics_payload()
+
+
+@router.post("/check")
+def run_diagnostics_check(body: DiagnosticsCheckRequest):
+ diagnostics = _diagnostics_payload()
+ result = _build_check_result(body.target, diagnostics)
+ dashboard_state = get_dashboard_state()
+ last_checks = dashboard_state.setdefault("last_checks", {})
+ last_checks[body.target] = result
+ return result
diff --git a/server/web/static/dashboard.css b/server/web/static/dashboard.css
new file mode 100644
index 0000000..5c3afe9
--- /dev/null
+++ b/server/web/static/dashboard.css
@@ -0,0 +1,328 @@
+:root{
+ --bg:#f4f6ef;
+ --bg-strong:#eef4e8;
+ --surface:rgba(255,255,255,.8);
+ --surface-strong:rgba(255,255,255,.92);
+ --surface-soft:rgba(252,253,249,.7);
+ --stroke:rgba(35,73,45,.1);
+ --stroke-strong:rgba(35,73,45,.18);
+ --text:#173225;
+ --muted:#5b6d60;
+ --green:#23613b;
+ --green-soft:#cfe5c8;
+ --teal:#7bb8a7;
+ --danger:#be5f4d;
+ --shadow:0 24px 60px rgba(28,74,46,.08);
+ --shadow-soft:0 12px 28px rgba(28,74,46,.06);
+ --radius-xl:28px;
+ --radius-lg:22px;
+ --radius-md:18px;
+ --radius-sm:14px;
+ --gap-xl:24px;
+ --gap-lg:18px;
+ --gap-md:14px;
+ --gap-sm:10px;
+ color-scheme:light;
+}
+*{box-sizing:border-box}
+html,body{margin:0;padding:0}
+body{
+ min-height:100vh;
+ font-family:"SF Pro Display","Pretendard Variable","Pretendard","Noto Sans KR","Noto Sans JP","Noto Sans SC",sans-serif;
+ color:var(--text);
+ background:
+ radial-gradient(circle at top left,rgba(141,202,148,.24),transparent 24%),
+ radial-gradient(circle at top right,rgba(123,184,167,.16),transparent 20%),
+ linear-gradient(180deg,var(--bg) 0%,var(--bg-strong) 100%);
+}
+button,input,select,textarea{font:inherit;color:inherit}
+img{display:block;max-width:100%}
+button{cursor:pointer}
+h1,h2,h3,h4,p,dl,dd,dt,pre{margin:0}
+body,button,input,select,textarea,p,h1,h2,h3,h4,dt,dd,span,strong,small,pre{overflow-wrap:anywhere}
+.page-shell{
+ width:min(calc(100% - 28px),1520px);
+ margin:0 auto;
+ padding:22px 0 28px;
+ display:grid;
+ grid-template-columns:minmax(260px,290px) minmax(0,1fr);
+ gap:var(--gap-xl);
+}
+.surface,.section-card,.metric-card,.status-stack{
+ background:linear-gradient(180deg,var(--surface-strong),var(--surface));
+ border:1px solid var(--stroke);
+ box-shadow:var(--shadow);
+ border-radius:var(--radius-xl);
+}
+.subtle{
+ background:linear-gradient(180deg,var(--surface-soft),rgba(255,255,255,.84));
+ border:1px solid rgba(35,73,45,.08);
+ border-radius:var(--radius-lg);
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.7);
+ padding:var(--gap-lg);
+}
+.sidebar{
+ position:sticky;
+ top:18px;
+ align-self:start;
+ height:calc(100vh - 36px);
+ padding:22px;
+ display:grid;
+ gap:var(--gap-lg);
+}
+.brand-lockup,.workspace,.topbar-copy,.hero-copy,.headline-lockup,.section-card,.status-stack,.field,.editor-grid,.stack-gap{display:grid;gap:var(--gap-md)}
+.workspace{gap:var(--gap-lg)}
+.kicker{
+ font-size:11px;
+ line-height:1.2;
+ letter-spacing:.14em;
+ text-transform:uppercase;
+ font-weight:800;
+ color:var(--green);
+}
+.title-clamp{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+.body-copy{font-size:.95rem;line-height:1.6;color:var(--muted)}
+.nav{display:grid;gap:8px}
+.nav button{
+ display:grid;
+ gap:4px;
+ text-align:left;
+ padding:14px 15px;
+ border-radius:var(--radius-md);
+ border:1px solid transparent;
+ background:rgba(255,255,255,.46);
+ transition:transform .16s ease,border-color .16s ease,background .16s ease;
+}
+.nav button:hover,.nav button.active{
+ transform:translateY(-1px);
+ border-color:var(--stroke-strong);
+ background:linear-gradient(180deg,rgba(255,255,255,.98),rgba(232,242,228,.9));
+}
+.nav-label{font-size:1rem}
+.nav-meta{font-size:.82rem;line-height:1.4}
+.status-stack{padding:18px;gap:var(--gap-md)}
+.status-row,.chip-wrap,.action-wrap,.topbar-actions,.section-head,.form-grid,.panel-grid,.detail-row{display:flex;gap:var(--gap-sm);flex-wrap:wrap}
+.status-pill,.btn,select,input,textarea{
+ border-radius:999px;
+ border:1px solid var(--stroke);
+ background:rgba(255,255,255,.92);
+}
+.status-pill{
+ display:inline-flex;
+ align-items:center;
+ gap:8px;
+ padding:9px 12px;
+ font-size:.86rem;
+ font-weight:700;
+}
+.live-pill{padding-inline:12px 14px}
+.dot{
+ width:10px;
+ height:10px;
+ border-radius:50%;
+ background:#c3d1c7;
+ box-shadow:0 0 0 5px rgba(195,209,199,.25);
+}
+.dot.live{
+ background:#4ea56a;
+ box-shadow:0 0 0 6px rgba(78,165,106,.22);
+}
+.topbar{
+ padding:24px 26px;
+ display:grid;
+ grid-template-columns:minmax(0,1fr) auto;
+ gap:var(--gap-lg);
+ align-items:end;
+}
+.locale-field{display:grid;gap:8px;min-width:170px}
+.field-label{font-size:.82rem;font-weight:800;color:var(--muted)}
+.action-wrap{align-items:center}
+.btn{
+ padding:11px 16px;
+ font-weight:700;
+ box-shadow:var(--shadow-soft);
+}
+.btn.primary{
+ color:#fff;
+ background:linear-gradient(135deg,#235f3c,#4d9860);
+ border-color:transparent;
+}
+.btn.secondary{
+ background:linear-gradient(135deg,#ffffff,#edf4e9);
+}
+.btn.ghost{
+ background:rgba(255,255,255,.7);
+ box-shadow:none;
+}
+.btn.danger{
+ color:#fff;
+ background:linear-gradient(135deg,#d36f5c,var(--danger));
+ border-color:transparent;
+}
+.btn.small{padding:8px 12px;font-size:.86rem}
+.tab{display:none;gap:var(--gap-lg)}
+.tab.active{display:grid}
+.hero{
+ padding:26px;
+ display:grid;
+ grid-template-columns:minmax(0,1fr) minmax(240px,320px);
+ gap:var(--gap-lg);
+ align-items:stretch;
+}
+.hero-title{font-size:clamp(2rem,4vw,3.6rem);letter-spacing:-.05em}
+.hero-figure{
+ position:relative;
+ min-height:300px;
+ border-radius:calc(var(--radius-xl) - 6px);
+ background:
+ radial-gradient(circle at 30% 18%,rgba(255,255,255,.92),rgba(255,255,255,.35) 36%,transparent 56%),
+ linear-gradient(180deg,rgba(226,240,220,.95),rgba(245,249,241,.92));
+ border:1px solid rgba(35,73,45,.08);
+ display:flex;
+ align-items:flex-end;
+ justify-content:center;
+ padding:24px;
+ overflow:hidden;
+}
+.hero-note{
+ position:absolute;
+ top:16px;
+ right:16px;
+ max-width:220px;
+ padding:12px 14px;
+ border-radius:var(--radius-md);
+ background:rgba(255,255,255,.88);
+ border:1px solid rgba(35,73,45,.08);
+}
+#hero-mascot{
+ width:min(100%,280px);
+ filter:drop-shadow(0 22px 36px rgba(35,97,59,.16));
+}
+.metric-grid{display:grid;gap:var(--gap-md);grid-template-columns:repeat(4,minmax(0,1fr))}
+.metric-card{padding:18px}
+.metric-label{font-size:.82rem;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}
+.metric-value{font-size:clamp(1.6rem,3vw,2.6rem);font-weight:900;letter-spacing:-.05em}
+.panel-grid{display:grid;gap:var(--gap-md)}
+.panel-grid.triple{grid-template-columns:repeat(3,minmax(0,1fr))}
+.panel-grid.quad{grid-template-columns:repeat(4,minmax(0,1fr))}
+.panel-grid.dual{grid-template-columns:repeat(2,minmax(0,1fr))}
+.section-card{padding:20px}
+.section-head{justify-content:space-between;align-items:flex-start}
+.section-head.compact{margin-bottom:var(--gap-sm)}
+.data-list{display:grid;gap:var(--gap-sm)}
+.data-item,.memory-item,.integration-item,.conversation-item,.schedule-item,.chat-bubble{
+ padding:14px 15px;
+ border-radius:var(--radius-md);
+ background:rgba(255,255,255,.7);
+ border:1px solid rgba(35,73,45,.08);
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.7);
+}
+.data-item strong,.conversation-item strong,.schedule-item strong,.integration-item strong{display:block;margin-bottom:4px}
+.badge-row{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
+.badge{
+ display:inline-flex;
+ align-items:center;
+ padding:6px 10px;
+ border-radius:999px;
+ background:rgba(235,244,231,.95);
+ border:1px solid rgba(35,73,45,.08);
+ font-size:.8rem;
+ font-weight:700;
+}
+.detail-list{
+ display:grid;
+ gap:8px;
+ grid-template-columns:minmax(110px,160px) minmax(0,1fr);
+}
+.detail-list dt{
+ font-size:.82rem;
+ font-weight:800;
+ color:var(--muted);
+}
+.detail-list dd{font-size:.95rem}
+.editor-grid{padding-top:4px}
+.form-grid.dual,.memory-layout,.chat-layout{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--gap-md)}
+.field{gap:8px}
+select,input,textarea,pre{
+ width:100%;
+ padding:13px 14px;
+ border-radius:var(--radius-sm);
+ border:1px solid rgba(35,73,45,.12);
+ background:rgba(255,255,255,.9);
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.8);
+}
+textarea{
+ min-height:200px;
+ resize:vertical;
+}
+#memory-editor{min-height:460px}
+#chat-input{min-height:220px}
+pre{
+ white-space:pre-wrap;
+ word-break:break-word;
+ min-height:320px;
+ max-height:680px;
+ overflow:auto;
+ font-size:.85rem;
+ line-height:1.55;
+ font-family:"SF Mono","Roboto Mono","Menlo",Consolas,monospace;
+}
+.feed{
+ display:grid;
+ gap:var(--gap-sm);
+ max-height:680px;
+ overflow:auto;
+}
+.chat-bubble.assistant{
+ background:linear-gradient(180deg,rgba(235,244,231,.96),rgba(255,255,255,.88));
+}
+.memory-item{
+ display:flex;
+ justify-content:space-between;
+ align-items:center;
+ gap:var(--gap-sm);
+}
+.memory-item.active{
+ border-color:var(--stroke-strong);
+ background:linear-gradient(180deg,rgba(240,247,236,.98),rgba(255,255,255,.92));
+}
+.schedule-form{padding:4px 0}
+.empty-state{
+ padding:18px;
+ border-radius:var(--radius-md);
+ background:rgba(255,255,255,.64);
+ border:1px dashed rgba(35,73,45,.14);
+ color:var(--muted);
+ text-align:center;
+}
+.toasts{
+ position:fixed;
+ right:20px;
+ bottom:20px;
+ z-index:40;
+ display:grid;
+ gap:10px;
+ width:min(360px,calc(100vw - 24px));
+}
+.toast{
+ padding:14px 16px;
+ border-radius:var(--radius-md);
+ background:rgba(255,255,255,.94);
+ border:1px solid rgba(35,73,45,.12);
+ box-shadow:var(--shadow-soft);
+}
+@media (max-width:1280px){
+ .panel-grid.triple,.panel-grid.quad{grid-template-columns:repeat(2,minmax(0,1fr))}
+}
+@media (max-width:1080px){
+ .page-shell{grid-template-columns:1fr}
+ .sidebar{position:static;height:auto}
+ .topbar,.hero{grid-template-columns:1fr}
+}
+@media (max-width:760px){
+ .page-shell{width:min(calc(100% - 18px),1520px);padding-top:12px}
+ .surface,.section-card,.metric-card,.status-stack{border-radius:22px}
+ .sidebar,.topbar,.hero,.section-card,.metric-card{padding:16px}
+ .metric-grid,.panel-grid.triple,.panel-grid.quad,.panel-grid.dual,.form-grid.dual,.memory-layout,.chat-layout{grid-template-columns:1fr}
+ .topbar-actions{justify-content:flex-start}
+}
diff --git a/server/web/static/dashboard.js b/server/web/static/dashboard.js
new file mode 100644
index 0000000..ba1de56
--- /dev/null
+++ b/server/web/static/dashboard.js
@@ -0,0 +1,1875 @@
+const LOCALES = {
+ en: { intl: "en-US", html: "en" },
+ ko: { intl: "ko-KR", html: "ko" },
+ ja: { intl: "ja-JP", html: "ja" },
+ zh: { intl: "zh-CN", html: "zh-CN" },
+};
+
+const COPY = {
+ en: {
+ meta: { title: "ccoli dashboard" },
+ brand: {
+ kicker: "ccoli dashboard",
+ title: "A calmer control room for voice runtime work.",
+ body: "English is the default UI. Switch the interface language without changing STT, TTS, or assistant response defaults.",
+ },
+ locale: { options: { en: "English", ko: "Korean", ja: "Japanese", zh: "Chinese" } },
+ nav: {
+ overview: { label: "Overview", body: "Live summary" },
+ diagnostics: { label: "Diagnostics", body: "Runtime checks" },
+ memory: { label: "Memory", body: "Editable files" },
+ conversation: { label: "Conversation", body: "History and speakers" },
+ schedules: { label: "Schedules", body: "Reminders and alarms" },
+ chat: { label: "Chat", body: "Browser conversation" },
+ logs: { label: "Logs", body: "Live stream" },
+ },
+ top: {
+ kicker: "multilingual dashboard",
+ title: "Runtime status with room to breathe.",
+ body: "Short titles stay short. Longer explanations move into body text so the layout holds across English, Korean, Japanese, and Chinese.",
+ localeLabel: "Language",
+ reconnect: "Reconnect live",
+ refresh: "Refresh all",
+ },
+ overview: {
+ heroKicker: "overview",
+ actions: { chat: "Open chat", memory: "Open memory" },
+ metrics: {
+ mode: "Mode",
+ emotion: "Emotion",
+ conversation: "Conversation",
+ connection: "Connection",
+ },
+ schedules: { title: "Upcoming schedules", body: "Next reminders and timers.", action: "Open schedules" },
+ conversation: { title: "Recent conversation", body: "Latest context at a glance.", action: "Open history" },
+ logs: { title: "Latest logs", body: "Recent runtime events.", action: "Open logs" },
+ },
+ diagnostics: {
+ kicker: "diagnostics",
+ title: "Safe runtime diagnostics and minimal controls.",
+ body: "Use curated settings here instead of patching raw config values by hand.",
+ refresh: "Refresh diagnostics",
+ check: "Run check",
+ save: "Save changes",
+ stt: {
+ title: "STT",
+ body: "Review runtime device selection, model size, and active notes.",
+ device: "Configured device",
+ modelSize: "Model size",
+ },
+ connection: {
+ title: "Connection",
+ body: "Inspect transport status and update the preferred connection mode.",
+ mode: "Connection mode",
+ },
+ integrations: {
+ title: "Integrations",
+ body: "Health checks and enable or disable controls stay here.",
+ refresh: "Refresh integrations",
+ },
+ checkResults: {
+ title: "Recent checks",
+ body: "Lightweight checks are stored here for quick review.",
+ llmAction: "Check LLM",
+ },
+ advanced: {
+ title: "Advanced",
+ body: "Token storage and raw config snapshot are still available, but no longer drive the main workflow.",
+ token: "X-Auth-Token",
+ tokenPlaceholder: "Enter the dashboard token if authentication is enabled.",
+ saveToken: "Save token",
+ clearToken: "Clear token",
+ refreshConfig: "Refresh config snapshot",
+ },
+ options: {
+ deviceCpu: "CPU",
+ deviceCuda: "CUDA",
+ modeAuto: "Auto",
+ modeWired: "Wired",
+ modeWifi: "Wi-Fi",
+ },
+ },
+ memory: {
+ kicker: "memory",
+ title: "Edit the assistant memory files directly.",
+ body: "Titles stay compact while the full content lives in the editor.",
+ reload: "Reload file",
+ save: "Save memory",
+ filesTitle: "Files",
+ filesBody: "Pick a file to load it into the editor.",
+ },
+ conversation: {
+ kicker: "conversation",
+ title: "Review turn history and speaker context.",
+ body: "Filter by speaker and keep long utterances in the body area.",
+ refresh: "Refresh history",
+ clear: "Clear history",
+ filters: {
+ speaker: "Speaker filter",
+ limit: "Items to load",
+ allSpeakers: "All speakers",
+ },
+ },
+ schedules: {
+ kicker: "schedules",
+ title: "Manage reminders in one place.",
+ body: "Long schedule descriptions stay in body text instead of the title line.",
+ refresh: "Refresh schedules",
+ add: "Add schedule",
+ fields: {
+ title: "Title",
+ titlePlaceholder: "Example: Monday demo prep",
+ datetime: "Date and time",
+ reminder: "Reminder lead time (minutes)",
+ description: "Description",
+ descriptionPlaceholder: "Optional note",
+ },
+ },
+ chat: {
+ kicker: "chat",
+ title: "Talk to ccoli from the browser.",
+ body: "Web chat stays available while the diagnostics view handles runtime settings.",
+ composeTitle: "Send a message",
+ composeBody: "Chat uses `/api/chat` and keeps the last messages in this browser session.",
+ speaker: "speaker_id",
+ message: "Message",
+ messagePlaceholder: "Type a message for ccoli.",
+ send: "Send",
+ clear: "Clear feed",
+ },
+ logs: {
+ kicker: "logs",
+ title: "Follow the live runtime stream.",
+ body: "Use the filter to narrow long output without changing the stored logs.",
+ refresh: "Refresh logs",
+ tail: "Recent line count",
+ filter: "Filter",
+ filterPlaceholder: "Example: ERROR, websocket, STT",
+ },
+ common: {
+ pause: "Pause",
+ resume: "Resume",
+ loading: "Loading...",
+ empty: "Nothing to show yet.",
+ open: "Open",
+ enabled: "Enabled",
+ disabled: "Disabled",
+ configured: "Configured",
+ needsConfig: "Needs config",
+ connected: "Connected",
+ disconnected: "Disconnected",
+ unknown: "Unknown",
+ notChecked: "Not checked",
+ active: "Active",
+ inactive: "Inactive",
+ complete: "Complete",
+ delete: "Delete",
+ yes: "Yes",
+ no: "No",
+ health: "Health",
+ saving: "Saved",
+ tokenSaved: "Token saved.",
+ tokenCleared: "Token cleared.",
+ refreshed: "Dashboard refreshed.",
+ refreshPartial: "Dashboard refreshed with some errors.",
+ messageRequired: "Enter a message first.",
+ historyCleared: "Conversation history cleared.",
+ scheduleAdded: "Schedule added.",
+ scheduleCompleted: "Schedule marked complete.",
+ scheduleDeleted: "Schedule deleted.",
+ chatReceived: "Chat response received.",
+ integrationUpdated: "Integration updated.",
+ diagnosticsUpdated: "Diagnostics updated.",
+ saveFailed: "Save failed",
+ loadFailed: "Load failed",
+ actionFailed: "Action failed",
+ checkCompleted: "Check completed.",
+ noData: "No data yet.",
+ },
+ },
+ ko: {
+ meta: { title: "ccoli dashboard" },
+ brand: {
+ kicker: "ccoli 대시보드",
+ title: "음성 런타임을 차분하게 다루는 제어실.",
+ body: "기본 UI는 English이며, 화면 언어만 전환합니다. STT, TTS, 응답 언어 기본값은 자동으로 바뀌지 않습니다.",
+ },
+ locale: { options: { en: "English", ko: "한국어", ja: "日本語", zh: "中文" } },
+ nav: {
+ overview: { label: "개요", body: "라이브 요약" },
+ diagnostics: { label: "진단", body: "런타임 점검" },
+ memory: { label: "메모리", body: "파일 편집" },
+ conversation: { label: "대화", body: "히스토리와 화자" },
+ schedules: { label: "일정", body: "리마인더와 알람" },
+ chat: { label: "채팅", body: "브라우저 대화" },
+ logs: { label: "로그", body: "실시간 스트림" },
+ },
+ top: {
+ kicker: "다국어 대시보드",
+ title: "여백이 살아 있는 런타임 상태 화면.",
+ body: "짧은 제목은 짧게 유지하고, 긴 설명은 본문으로 내려 여러 언어에서도 레이아웃을 지킵니다.",
+ localeLabel: "언어",
+ reconnect: "실시간 재연결",
+ refresh: "전체 새로고침",
+ },
+ overview: {
+ heroKicker: "개요",
+ actions: { chat: "채팅 열기", memory: "메모리 열기" },
+ metrics: { mode: "모드", emotion: "감정", conversation: "대화 수", connection: "연결" },
+ schedules: { title: "다가오는 일정", body: "다음 리마인더와 타이머입니다.", action: "일정 열기" },
+ conversation: { title: "최근 대화", body: "최신 대화 맥락을 빠르게 봅니다.", action: "히스토리 열기" },
+ logs: { title: "최근 로그", body: "방금 발생한 런타임 이벤트입니다.", action: "로그 열기" },
+ },
+ diagnostics: {
+ kicker: "진단",
+ title: "안전한 런타임 진단과 최소 설정 제어.",
+ body: "이제 raw config patch 대신 의미가 분명한 제어만 제공합니다.",
+ refresh: "진단 새로고침",
+ check: "점검 실행",
+ save: "변경 저장",
+ stt: {
+ title: "STT",
+ body: "디바이스 선택, 모델 크기, 런타임 메모를 확인합니다.",
+ device: "설정 디바이스",
+ modelSize: "모델 크기",
+ },
+ connection: {
+ title: "연결",
+ body: "전송 경로 상태를 확인하고 기본 연결 모드를 바꿉니다.",
+ mode: "연결 모드",
+ },
+ integrations: {
+ title: "연동",
+ body: "헬스 체크와 활성화 제어를 여기서 관리합니다.",
+ refresh: "연동 새로고침",
+ },
+ checkResults: {
+ title: "최근 점검",
+ body: "가벼운 점검 결과를 여기 보관합니다.",
+ llmAction: "LLM 점검",
+ },
+ advanced: {
+ title: "고급",
+ body: "토큰 저장과 raw config 스냅샷은 남겨 두되, 메인 흐름에서는 뒤로 뺐습니다.",
+ token: "X-Auth-Token",
+ tokenPlaceholder: "인증이 켜져 있다면 토큰을 입력하세요.",
+ saveToken: "토큰 저장",
+ clearToken: "토큰 지우기",
+ refreshConfig: "config 새로고침",
+ },
+ options: {
+ deviceCpu: "CPU",
+ deviceCuda: "CUDA",
+ modeAuto: "자동",
+ modeWired: "유선",
+ modeWifi: "Wi-Fi",
+ },
+ },
+ memory: {
+ kicker: "메모리",
+ title: "어시스턴트 메모리 파일을 직접 편집합니다.",
+ body: "제목은 짧게, 긴 내용은 편집기 안에서 다룹니다.",
+ reload: "파일 다시 읽기",
+ save: "메모리 저장",
+ filesTitle: "파일 목록",
+ filesBody: "파일을 선택하면 편집기에 로드됩니다.",
+ },
+ conversation: {
+ kicker: "대화",
+ title: "대화 히스토리와 화자 맥락을 확인합니다.",
+ body: "화자별로 필터링하고 긴 발화는 본문 영역에서 읽습니다.",
+ refresh: "히스토리 새로고침",
+ clear: "히스토리 비우기",
+ filters: {
+ speaker: "화자 필터",
+ limit: "불러올 개수",
+ allSpeakers: "전체 화자",
+ },
+ },
+ schedules: {
+ kicker: "일정",
+ title: "리마인더를 한 곳에서 관리합니다.",
+ body: "긴 설명은 제목이 아니라 본문에 둡니다.",
+ refresh: "일정 새로고침",
+ add: "일정 추가",
+ fields: {
+ title: "제목",
+ titlePlaceholder: "예: 월요일 데모 준비",
+ datetime: "날짜와 시간",
+ reminder: "미리 알림(분)",
+ description: "설명",
+ descriptionPlaceholder: "선택 메모",
+ },
+ },
+ chat: {
+ kicker: "채팅",
+ title: "브라우저에서 ccoli와 대화합니다.",
+ body: "런타임 설정은 Diagnostics로 옮기고, 이 화면은 대화에 집중합니다.",
+ composeTitle: "메시지 보내기",
+ composeBody: "웹 채팅은 `/api/chat`을 사용하고 현재 브라우저 세션에 최근 메시지를 유지합니다.",
+ speaker: "speaker_id",
+ message: "메시지",
+ messagePlaceholder: "ccoli에게 보낼 메시지를 입력하세요.",
+ send: "보내기",
+ clear: "피드 비우기",
+ },
+ logs: {
+ kicker: "로그",
+ title: "실시간 런타임 스트림을 확인합니다.",
+ body: "필터로 긴 출력을 좁혀 볼 수 있습니다.",
+ refresh: "로그 새로고침",
+ tail: "최근 줄 수",
+ filter: "필터",
+ filterPlaceholder: "예: ERROR, websocket, STT",
+ },
+ common: {
+ pause: "일시정지",
+ resume: "재개",
+ loading: "불러오는 중...",
+ empty: "표시할 내용이 아직 없습니다.",
+ open: "열기",
+ enabled: "활성",
+ disabled: "비활성",
+ configured: "구성됨",
+ needsConfig: "구성 필요",
+ connected: "연결됨",
+ disconnected: "연결 안 됨",
+ unknown: "알 수 없음",
+ notChecked: "미확인",
+ active: "동작 중",
+ inactive: "비활성",
+ complete: "완료 처리",
+ delete: "삭제",
+ yes: "예",
+ no: "아니오",
+ health: "헬스",
+ saving: "저장했습니다.",
+ tokenSaved: "토큰을 저장했습니다.",
+ tokenCleared: "토큰을 지웠습니다.",
+ refreshed: "대시보드를 새로고침했습니다.",
+ refreshPartial: "일부 오류와 함께 새로고침했습니다.",
+ messageRequired: "메시지를 먼저 입력해주세요.",
+ historyCleared: "대화 히스토리를 비웠습니다.",
+ scheduleAdded: "일정을 추가했습니다.",
+ scheduleCompleted: "일정을 완료 처리했습니다.",
+ scheduleDeleted: "일정을 삭제했습니다.",
+ chatReceived: "채팅 응답을 받았습니다.",
+ integrationUpdated: "연동 상태를 반영했습니다.",
+ diagnosticsUpdated: "진단 정보를 갱신했습니다.",
+ saveFailed: "저장 실패",
+ loadFailed: "로드 실패",
+ actionFailed: "작업 실패",
+ checkCompleted: "점검을 완료했습니다.",
+ noData: "아직 데이터가 없습니다.",
+ },
+ },
+ ja: {
+ meta: { title: "ccoli dashboard" },
+ brand: {
+ kicker: "ccoli ダッシュボード",
+ title: "音声ランタイムを静かに扱うコントロールルーム。",
+ body: "既定 UI は English です。画面言語だけを切り替え、STT・TTS・応答言語の既定値は変えません。",
+ },
+ locale: { options: { en: "English", ko: "韓国語", ja: "日本語", zh: "中国語" } },
+ nav: {
+ overview: { label: "概要", body: "ライブ要約" },
+ diagnostics: { label: "診断", body: "ランタイム確認" },
+ memory: { label: "メモリ", body: "ファイル編集" },
+ conversation: { label: "会話", body: "履歴と話者" },
+ schedules: { label: "予定", body: "リマインダー" },
+ chat: { label: "チャット", body: "ブラウザ会話" },
+ logs: { label: "ログ", body: "ライブ出力" },
+ },
+ top: {
+ kicker: "多言語ダッシュボード",
+ title: "余白を保ったランタイム画面。",
+ body: "短い見出しは短く保ち、長い説明は本文へ移して各言語でもレイアウトを守ります。",
+ localeLabel: "言語",
+ reconnect: "ライブ再接続",
+ refresh: "全体更新",
+ },
+ overview: {
+ heroKicker: "概要",
+ actions: { chat: "チャットを開く", memory: "メモリを開く" },
+ metrics: { mode: "モード", emotion: "感情", conversation: "会話数", connection: "接続" },
+ schedules: { title: "今後の予定", body: "次のリマインダーとタイマー。", action: "予定を開く" },
+ conversation: { title: "最近の会話", body: "最新の文脈をすばやく確認。", action: "履歴を開く" },
+ logs: { title: "最近のログ", body: "直近のランタイムイベント。", action: "ログを開く" },
+ },
+ diagnostics: {
+ kicker: "診断",
+ title: "安全な診断と最小限の設定操作。",
+ body: "意味の分かる設定だけを残し、raw config patch は前面から外しました。",
+ refresh: "診断を更新",
+ check: "チェック実行",
+ save: "変更を保存",
+ stt: {
+ title: "STT",
+ body: "デバイス選択、モデルサイズ、ランタイムメモを確認します。",
+ device: "設定デバイス",
+ modelSize: "モデルサイズ",
+ },
+ connection: {
+ title: "接続",
+ body: "転送経路の状態を確認し、基本接続モードを変更します。",
+ mode: "接続モード",
+ },
+ integrations: {
+ title: "連携",
+ body: "ヘルスチェックと有効化をここで管理します。",
+ refresh: "連携を更新",
+ },
+ checkResults: {
+ title: "最近のチェック",
+ body: "軽量チェック結果をここに保持します。",
+ llmAction: "LLM を確認",
+ },
+ advanced: {
+ title: "詳細設定",
+ body: "トークン保存と raw config スナップショットは残しますが、主導線からは外しています。",
+ token: "X-Auth-Token",
+ tokenPlaceholder: "認証が有効ならトークンを入力してください。",
+ saveToken: "トークン保存",
+ clearToken: "トークン削除",
+ refreshConfig: "config 更新",
+ },
+ options: {
+ deviceCpu: "CPU",
+ deviceCuda: "CUDA",
+ modeAuto: "自動",
+ modeWired: "有線",
+ modeWifi: "Wi-Fi",
+ },
+ },
+ memory: {
+ kicker: "メモリ",
+ title: "アシスタントのメモリファイルを直接編集します。",
+ body: "見出しは短く、長い内容はエディタ側で扱います。",
+ reload: "再読み込み",
+ save: "メモリ保存",
+ filesTitle: "ファイル",
+ filesBody: "ファイルを選ぶとエディタへ読み込みます。",
+ },
+ conversation: {
+ kicker: "会話",
+ title: "会話履歴と話者コンテキストを確認します。",
+ body: "話者で絞り込み、長い発話は本文領域で読みます。",
+ refresh: "履歴更新",
+ clear: "履歴削除",
+ filters: {
+ speaker: "話者フィルタ",
+ limit: "読み込み件数",
+ allSpeakers: "すべての話者",
+ },
+ },
+ schedules: {
+ kicker: "予定",
+ title: "リマインダーを一か所で管理します。",
+ body: "長い説明は見出しではなく本文へ置きます。",
+ refresh: "予定更新",
+ add: "予定追加",
+ fields: {
+ title: "タイトル",
+ titlePlaceholder: "例: 月曜デモ準備",
+ datetime: "日時",
+ reminder: "事前通知(分)",
+ description: "説明",
+ descriptionPlaceholder: "任意メモ",
+ },
+ },
+ chat: {
+ kicker: "チャット",
+ title: "ブラウザから ccoli と会話します。",
+ body: "ランタイム設定は Diagnostics に移し、この画面は会話に集中します。",
+ composeTitle: "メッセージ送信",
+ composeBody: "Web チャットは `/api/chat` を使い、このブラウザに最近のメッセージを保持します。",
+ speaker: "speaker_id",
+ message: "メッセージ",
+ messagePlaceholder: "ccoli へのメッセージを入力してください。",
+ send: "送信",
+ clear: "フィードを消去",
+ },
+ logs: {
+ kicker: "ログ",
+ title: "ライブランタイムストリームを確認します。",
+ body: "フィルタで長い出力を絞り込めます。",
+ refresh: "ログ更新",
+ tail: "行数",
+ filter: "フィルタ",
+ filterPlaceholder: "例: ERROR, websocket, STT",
+ },
+ common: {
+ pause: "一時停止",
+ resume: "再開",
+ loading: "読み込み中...",
+ empty: "まだ表示できる内容がありません。",
+ open: "開く",
+ enabled: "有効",
+ disabled: "無効",
+ configured: "設定済み",
+ needsConfig: "設定必要",
+ connected: "接続中",
+ disconnected: "未接続",
+ unknown: "不明",
+ notChecked: "未確認",
+ active: "動作中",
+ inactive: "無効",
+ complete: "完了",
+ delete: "削除",
+ yes: "はい",
+ no: "いいえ",
+ health: "ヘルス",
+ saving: "保存しました。",
+ tokenSaved: "トークンを保存しました。",
+ tokenCleared: "トークンを消去しました。",
+ refreshed: "ダッシュボードを更新しました。",
+ refreshPartial: "一部エラー付きで更新しました。",
+ messageRequired: "先にメッセージを入力してください。",
+ historyCleared: "会話履歴を消去しました。",
+ scheduleAdded: "予定を追加しました。",
+ scheduleCompleted: "予定を完了にしました。",
+ scheduleDeleted: "予定を削除しました。",
+ chatReceived: "チャット応答を受信しました。",
+ integrationUpdated: "連携状態を反映しました。",
+ diagnosticsUpdated: "診断情報を更新しました。",
+ saveFailed: "保存失敗",
+ loadFailed: "読み込み失敗",
+ actionFailed: "操作失敗",
+ checkCompleted: "チェックが完了しました。",
+ noData: "まだデータがありません。",
+ },
+ },
+ zh: {
+ meta: { title: "ccoli dashboard" },
+ brand: {
+ kicker: "ccoli 仪表板",
+ title: "更安静的语音运行时控制室。",
+ body: "默认 UI 为 English。这里只切换界面语言,不自动修改 STT、TTS 或助手回复语言默认值。",
+ },
+ locale: { options: { en: "English", ko: "韩语", ja: "日语", zh: "中文" } },
+ nav: {
+ overview: { label: "总览", body: "实时摘要" },
+ diagnostics: { label: "诊断", body: "运行时检查" },
+ memory: { label: "记忆", body: "文件编辑" },
+ conversation: { label: "对话", body: "历史与说话人" },
+ schedules: { label: "日程", body: "提醒与闹钟" },
+ chat: { label: "聊天", body: "浏览器会话" },
+ logs: { label: "日志", body: "实时输出" },
+ },
+ top: {
+ kicker: "多语言仪表板",
+ title: "保留留白的运行时状态界面。",
+ body: "短标题保持短小,较长说明放到正文区域,这样在多种语言下也能维持布局稳定。",
+ localeLabel: "语言",
+ reconnect: "重新连接实时流",
+ refresh: "全部刷新",
+ },
+ overview: {
+ heroKicker: "总览",
+ actions: { chat: "打开聊天", memory: "打开记忆" },
+ metrics: { mode: "模式", emotion: "情绪", conversation: "对话数", connection: "连接" },
+ schedules: { title: "即将到来的日程", body: "下一批提醒和计时器。", action: "打开日程" },
+ conversation: { title: "最近对话", body: "快速查看最新上下文。", action: "打开历史" },
+ logs: { title: "最近日志", body: "刚刚发生的运行时事件。", action: "打开日志" },
+ },
+ diagnostics: {
+ kicker: "诊断",
+ title: "安全的运行时诊断与最小控制。",
+ body: "主要流程改为清晰的受控设置,不再依赖手工 patch 原始配置。",
+ refresh: "刷新诊断",
+ check: "执行检查",
+ save: "保存更改",
+ stt: {
+ title: "STT",
+ body: "查看设备选择、模型大小和运行时说明。",
+ device: "配置设备",
+ modelSize: "模型大小",
+ },
+ connection: {
+ title: "连接",
+ body: "检查传输状态,并修改首选连接模式。",
+ mode: "连接模式",
+ },
+ integrations: {
+ title: "集成",
+ body: "健康检查和启用控制都放在这里。",
+ refresh: "刷新集成",
+ },
+ checkResults: {
+ title: "最近检查",
+ body: "轻量检查结果会保存在这里。",
+ llmAction: "检查 LLM",
+ },
+ advanced: {
+ title: "高级",
+ body: "保留令牌存储和 raw config 快照,但不再作为主要操作入口。",
+ token: "X-Auth-Token",
+ tokenPlaceholder: "如果启用了认证,请在这里输入令牌。",
+ saveToken: "保存令牌",
+ clearToken: "清除令牌",
+ refreshConfig: "刷新 config",
+ },
+ options: {
+ deviceCpu: "CPU",
+ deviceCuda: "CUDA",
+ modeAuto: "自动",
+ modeWired: "有线",
+ modeWifi: "Wi-Fi",
+ },
+ },
+ memory: {
+ kicker: "记忆",
+ title: "直接编辑助手记忆文件。",
+ body: "标题保持简短,长内容放在编辑器里。",
+ reload: "重新加载文件",
+ save: "保存记忆",
+ filesTitle: "文件列表",
+ filesBody: "选择文件后加载到编辑器。",
+ },
+ conversation: {
+ kicker: "对话",
+ title: "查看对话历史和说话人上下文。",
+ body: "可按说话人筛选,较长内容放在正文区域阅读。",
+ refresh: "刷新历史",
+ clear: "清空历史",
+ filters: {
+ speaker: "说话人筛选",
+ limit: "加载数量",
+ allSpeakers: "全部说话人",
+ },
+ },
+ schedules: {
+ kicker: "日程",
+ title: "在一个页面管理提醒。",
+ body: "较长说明放在正文,不挤占标题。",
+ refresh: "刷新日程",
+ add: "添加日程",
+ fields: {
+ title: "标题",
+ titlePlaceholder: "例如:周一演示准备",
+ datetime: "日期时间",
+ reminder: "提前提醒(分钟)",
+ description: "说明",
+ descriptionPlaceholder: "可选备注",
+ },
+ },
+ chat: {
+ kicker: "聊天",
+ title: "在浏览器里与 ccoli 对话。",
+ body: "运行时设置移到 Diagnostics,这里专注于对话。",
+ composeTitle: "发送消息",
+ composeBody: "Web chat 使用 `/api/chat`,并在当前浏览器会话中保留最近消息。",
+ speaker: "speaker_id",
+ message: "消息",
+ messagePlaceholder: "输入发给 ccoli 的消息。",
+ send: "发送",
+ clear: "清空内容",
+ },
+ logs: {
+ kicker: "日志",
+ title: "查看实时运行时输出。",
+ body: "可用过滤器缩小较长日志范围。",
+ refresh: "刷新日志",
+ tail: "最近行数",
+ filter: "过滤",
+ filterPlaceholder: "例如:ERROR, websocket, STT",
+ },
+ common: {
+ pause: "暂停",
+ resume: "继续",
+ loading: "加载中...",
+ empty: "暂时没有可显示的内容。",
+ open: "打开",
+ enabled: "已启用",
+ disabled: "已禁用",
+ configured: "已配置",
+ needsConfig: "需要配置",
+ connected: "已连接",
+ disconnected: "未连接",
+ unknown: "未知",
+ notChecked: "未检查",
+ active: "运行中",
+ inactive: "未激活",
+ complete: "完成",
+ delete: "删除",
+ yes: "是",
+ no: "否",
+ health: "健康",
+ saving: "已保存。",
+ tokenSaved: "已保存令牌。",
+ tokenCleared: "已清除令牌。",
+ refreshed: "已刷新仪表板。",
+ refreshPartial: "刷新完成,但有部分错误。",
+ messageRequired: "请先输入消息。",
+ historyCleared: "已清空对话历史。",
+ scheduleAdded: "已添加日程。",
+ scheduleCompleted: "已将日程标记为完成。",
+ scheduleDeleted: "已删除日程。",
+ chatReceived: "已收到聊天响应。",
+ integrationUpdated: "已更新集成状态。",
+ diagnosticsUpdated: "已更新诊断信息。",
+ saveFailed: "保存失败",
+ loadFailed: "加载失败",
+ actionFailed: "操作失败",
+ checkCompleted: "检查已完成。",
+ noData: "暂时没有数据。",
+ },
+ },
+};
+
+const STATE = {
+ locale: normalizeLocale(localStorage.getItem("ccoli.locale") || "en"),
+ tab: normalizeTab(localStorage.getItem("ccoli.activeTab") || "overview"),
+ token: localStorage.getItem("ccoli.authToken") || "",
+ memoryFile: localStorage.getItem("ccoli.activeMemory") || "Soul.md",
+ status: null,
+ diagnostics: null,
+ integrations: {},
+ integrationHealth: {},
+ conversations: [],
+ schedules: [],
+ logs: [],
+ chat: [],
+ configSnapshot: null,
+ ws: null,
+ wsHeartbeat: null,
+ wsRetry: null,
+ logsPaused: false,
+ lastChatSignature: "",
+};
+
+document.addEventListener("DOMContentLoaded", boot);
+
+function boot() {
+ bind();
+ $("#auth-token").value = STATE.token;
+ $("#locale-select").value = STATE.locale;
+ seedDate();
+ applyCopy();
+ renderChat();
+ renderLogs();
+ openTab(STATE.tab);
+ connectWs();
+ refreshAll(true);
+ window.setInterval(() => fetchStatus(true), 15000);
+ window.setInterval(() => fetchDiagnostics(true), 20000);
+ window.setInterval(() => fetchSchedules(true), 45000);
+ window.setInterval(() => fetchConversation(true), 30000);
+ window.setInterval(() => {
+ if (!STATE.logsPaused) fetchLogs(true);
+ }, 18000);
+}
+
+function bind() {
+ $("#nav").addEventListener("click", (event) => {
+ const button = event.target.closest("button[data-tab]");
+ if (button) openTab(button.dataset.tab);
+ });
+ $("#refresh-all").addEventListener("click", () => refreshAll());
+ $("#connect-ws").addEventListener("click", () => connectWs(true));
+ $("#locale-select").addEventListener("change", (event) => {
+ STATE.locale = normalizeLocale(event.target.value);
+ localStorage.setItem("ccoli.locale", STATE.locale);
+ applyCopy();
+ renderAll();
+ });
+ $("#diagnostics-refresh").addEventListener("click", () => refreshDiagnostics());
+ $("#stt-settings-form").addEventListener("submit", saveSttSettings);
+ $("#connection-settings-form").addEventListener("submit", saveConnectionSettings);
+ $("#stt-check").addEventListener("click", () => runDiagnosticCheck("stt"));
+ $("#connection-check").addEventListener("click", () => runDiagnosticCheck("connection"));
+ $("#llm-check").addEventListener("click", () => runDiagnosticCheck("llm"));
+ $("#integrations-refresh").addEventListener("click", () => fetchIntegrations());
+ $("#save-token").addEventListener("click", saveToken);
+ $("#clear-token").addEventListener("click", clearToken);
+ $("#config-refresh").addEventListener("click", () => fetchConfig());
+
+ $("#reload-memory").addEventListener("click", () => loadMemory(STATE.memoryFile));
+ $("#save-memory").addEventListener("click", saveMemory);
+
+ $("#conversation-refresh").addEventListener("click", () => fetchConversation());
+ $("#conversation-clear").addEventListener("click", clearConversation);
+ $("#speaker-filter").addEventListener("change", () => fetchConversation());
+ $("#conversation-limit").addEventListener("change", () => fetchConversation());
+
+ $("#schedules-refresh").addEventListener("click", () => fetchSchedules());
+ $("#schedule-form").addEventListener("submit", createSchedule);
+
+ $("#chat-form").addEventListener("submit", sendChat);
+ $("#chat-clear").addEventListener("click", clearChat);
+
+ $("#logs-refresh").addEventListener("click", () => fetchLogs());
+ $("#logs-pause").addEventListener("click", toggleLogs);
+ $("#log-tail").addEventListener("change", () => fetchLogs());
+ $("#log-filter").addEventListener("input", renderLogs);
+
+ document.body.addEventListener("click", (event) => {
+ const tabTrigger = event.target.closest("[data-tab-target]");
+ if (tabTrigger) {
+ openTab(tabTrigger.dataset.tabTarget);
+ if (tabTrigger.dataset.memoryFile) loadMemory(tabTrigger.dataset.memoryFile);
+ return;
+ }
+
+ const memoryButton = event.target.closest("[data-memory-file-button]");
+ if (memoryButton) {
+ loadMemory(memoryButton.dataset.memoryFileButton);
+ return;
+ }
+
+ const scheduleAction = event.target.closest("[data-schedule-action]");
+ if (scheduleAction) {
+ const scheduleId = Number(scheduleAction.dataset.scheduleId);
+ if (scheduleAction.dataset.scheduleAction === "complete") {
+ completeSchedule(scheduleId);
+ } else {
+ deleteSchedule(scheduleId);
+ }
+ return;
+ }
+
+ const integrationAction = event.target.closest("[data-integration-action]");
+ if (integrationAction) {
+ const name = integrationAction.dataset.integrationName;
+ if (integrationAction.dataset.integrationAction === "health") {
+ checkIntegration(name);
+ } else {
+ toggleIntegration(name, integrationAction.dataset.integrationEnabled !== "true");
+ }
+ }
+ });
+}
+
+function normalizeLocale(value) {
+ return Object.prototype.hasOwnProperty.call(LOCALES, value) ? value : "en";
+}
+
+function normalizeTab(value) {
+ return value === "settings" ? "diagnostics" : value;
+}
+
+function $(selector, root = document) {
+ return root.querySelector(selector);
+}
+
+function $$(selector, root = document) {
+ return Array.from(root.querySelectorAll(selector));
+}
+
+function copyFor(locale = STATE.locale) {
+ return COPY[locale] || COPY.en;
+}
+
+function resolveCopy(path, locale = STATE.locale) {
+ return path.split(".").reduce((current, part) => (current && current[part] !== undefined ? current[part] : undefined), copyFor(locale));
+}
+
+function t(path, fallback = "") {
+ const value = resolveCopy(path);
+ return typeof value === "string" ? value : fallback;
+}
+
+function uiLocale() {
+ return LOCALES[STATE.locale]?.intl || LOCALES.en.intl;
+}
+
+function applyCopy() {
+ document.documentElement.lang = LOCALES[STATE.locale]?.html || "en";
+ document.title = t("meta.title", "ccoli dashboard");
+
+ $$("[data-i18n]").forEach((node) => {
+ const value = t(node.dataset.i18n, "");
+ if (value) node.textContent = value;
+ });
+ $$("[data-i18n-placeholder]").forEach((node) => {
+ const value = t(node.dataset.i18nPlaceholder, "");
+ if (value) node.setAttribute("placeholder", value);
+ });
+
+ $("#logs-pause").textContent = STATE.logsPaused ? t("common.resume") : t("common.pause");
+ if ($("#locale-select").value !== STATE.locale) $("#locale-select").value = STATE.locale;
+}
+
+function renderAll() {
+ renderStatus();
+ renderDiagnostics();
+ renderMemoryList();
+ $("#memory-meta").textContent = $("#memory-editor").value ? `${formatNumber($("#memory-editor").value.length)} ${uiLabel("chars")}` : $("#memory-meta").textContent;
+ renderConversation();
+ renderSchedules();
+ renderOverviewConversation();
+ renderOverviewSchedules();
+ renderOverviewLogs();
+ renderIntegrations();
+ renderChat();
+ renderLogs();
+ renderConfigSnapshot();
+}
+
+function openTab(tab) {
+ STATE.tab = normalizeTab(tab);
+ localStorage.setItem("ccoli.activeTab", STATE.tab);
+ $$("#nav button").forEach((button) => button.classList.toggle("active", button.dataset.tab === STATE.tab));
+ $$(".tab").forEach((panel) => panel.classList.toggle("active", panel.id === `tab-${STATE.tab}`));
+ if (STATE.tab === "diagnostics") refreshDiagnostics(true);
+ if (STATE.tab === "logs") fetchLogs(true);
+ if (STATE.tab === "conversation") fetchConversation(true);
+}
+
+async function refreshAll(silent = false) {
+ const results = await Promise.allSettled([
+ fetchStatus(true),
+ fetchDiagnostics(true),
+ fetchMemoryList(true),
+ loadMemory(STATE.memoryFile, true),
+ fetchSpeakers(true),
+ fetchConversation(true),
+ fetchSchedules(true),
+ fetchIntegrations(true),
+ fetchLogs(true),
+ fetchConfig(true),
+ ]);
+ const failures = results.filter((item) => item.status === "rejected").length;
+ if (!silent) toast(failures ? t("common.refreshPartial") : t("common.refreshed"));
+}
+
+async function refreshDiagnostics(silent = false) {
+ const results = await Promise.allSettled([fetchDiagnostics(true), fetchIntegrations(true), fetchConfig(true)]);
+ const failures = results.filter((item) => item.status === "rejected").length;
+ if (!silent) toast(failures ? t("common.refreshPartial") : t("common.diagnosticsUpdated"));
+}
+
+async function api(path, options = {}) {
+ const headers = new Headers(options.headers || {});
+ if (STATE.token) headers.set("X-Auth-Token", STATE.token);
+ if (options.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
+
+ const response = await fetch(path, { ...options, headers });
+ const contentType = response.headers.get("content-type") || "";
+ const payload = contentType.includes("application/json") ? await response.json() : await response.text();
+ if (!response.ok) {
+ const detail = typeof payload === "string" ? payload : payload.detail || JSON.stringify(payload);
+ throw new Error(detail || `HTTP ${response.status}`);
+ }
+ return payload;
+}
+
+async function fetchStatus(silent = false) {
+ try {
+ STATE.status = await api("/api/status");
+ renderStatus();
+ } catch (error) {
+ if (!silent) toast(`${t("common.loadFailed")}: ${error.message}`);
+ throw error;
+ }
+}
+
+async function fetchDiagnostics(silent = false) {
+ try {
+ STATE.diagnostics = await api("/api/diagnostics/");
+ syncDiagnosticsControls();
+ renderDiagnostics();
+ renderStatus();
+ } catch (error) {
+ if (!silent) toast(`${t("common.loadFailed")}: ${error.message}`);
+ throw error;
+ }
+}
+
+async function runDiagnosticCheck(target) {
+ try {
+ const result = await api("/api/diagnostics/check", {
+ method: "POST",
+ body: JSON.stringify({ target }),
+ });
+ if (!STATE.diagnostics) STATE.diagnostics = { runtime: {}, stt: {}, connection: {}, last_checks: {} };
+ STATE.diagnostics.last_checks = STATE.diagnostics.last_checks || {};
+ STATE.diagnostics.last_checks[target] = result;
+ renderDiagnostics();
+ toast(result.summary || t("common.checkCompleted"));
+ } catch (error) {
+ toast(`${t("common.actionFailed")}: ${error.message}`);
+ }
+}
+
+function syncDiagnosticsControls() {
+ const diagnostics = STATE.diagnostics || {};
+ const stt = diagnostics.stt || {};
+ const connection = diagnostics.connection || {};
+
+ if (stt.configured_device) $("#stt-device").value = stt.configured_device;
+ if (stt.model_size) $("#stt-model-size").value = stt.model_size;
+ if (connection.mode) $("#connection-mode").value = connection.mode;
+}
+
+async function saveSttSettings(event) {
+ event.preventDefault();
+ try {
+ await api("/api/config/", {
+ method: "PATCH",
+ body: JSON.stringify({ section: "stt", key: "device", value: $("#stt-device").value }),
+ });
+ await api("/api/config/", {
+ method: "PATCH",
+ body: JSON.stringify({ section: "stt", key: "model_size", value: $("#stt-model-size").value }),
+ });
+ await Promise.allSettled([fetchDiagnostics(true), fetchConfig(true), fetchStatus(true)]);
+ toast(t("common.saving"));
+ } catch (error) {
+ toast(`${t("common.saveFailed")}: ${error.message}`);
+ }
+}
+
+async function saveConnectionSettings(event) {
+ event.preventDefault();
+ try {
+ await api("/api/config/", {
+ method: "PATCH",
+ body: JSON.stringify({ section: "connection", key: "mode", value: $("#connection-mode").value }),
+ });
+ await Promise.allSettled([fetchDiagnostics(true), fetchConfig(true), fetchStatus(true)]);
+ toast(t("common.saving"));
+ } catch (error) {
+ toast(`${t("common.saveFailed")}: ${error.message}`);
+ }
+}
+
+async function fetchConfig(silent = false) {
+ try {
+ STATE.configSnapshot = await api("/api/config/");
+ renderConfigSnapshot();
+ } catch (error) {
+ if (!silent) toast(`${t("common.loadFailed")}: ${error.message}`);
+ throw error;
+ }
+}
+
+function renderConfigSnapshot() {
+ $("#config-json").textContent = STATE.configSnapshot ? JSON.stringify(STATE.configSnapshot, null, 2) : t("common.loading");
+}
+
+async function fetchIntegrations(silent = false) {
+ try {
+ const payload = await api("/api/integrations/");
+ STATE.integrations = payload.integrations || {};
+ renderIntegrations();
+ } catch (error) {
+ if (!silent) toast(`${t("common.loadFailed")}: ${error.message}`);
+ throw error;
+ }
+}
+
+async function toggleIntegration(name, enabled) {
+ try {
+ await api(`/api/integrations/${encodeURIComponent(name)}/enabled`, {
+ method: "POST",
+ body: JSON.stringify({ enabled }),
+ });
+ STATE.integrations[name] = { ...(STATE.integrations[name] || {}), enabled };
+ renderIntegrations();
+ toast(t("common.integrationUpdated"));
+ } catch (error) {
+ toast(`${t("common.actionFailed")}: ${error.message}`);
+ }
+}
+
+async function checkIntegration(name) {
+ try {
+ const payload = await api(`/api/integrations/${encodeURIComponent(name)}/health`);
+ STATE.integrationHealth[name] = {
+ ok: payload.ok,
+ text: payload.ok ? t("common.connected") : (payload.error ? JSON.stringify(payload.error) : t("common.unknown")),
+ };
+ renderIntegrations();
+ toast(t("common.checkCompleted"));
+ } catch (error) {
+ STATE.integrationHealth[name] = { ok: false, text: error.message };
+ renderIntegrations();
+ toast(`${t("common.actionFailed")}: ${error.message}`);
+ }
+}
+
+function renderIntegrations() {
+ const root = $("#integration-list");
+ const names = Object.keys(STATE.integrations || {});
+ if (!names.length) {
+ root.innerHTML = emptyState(t("common.noData"));
+ return;
+ }
+
+ root.innerHTML = names.map((name) => {
+ const integration = STATE.integrations[name] || {};
+ const health = STATE.integrationHealth[name];
+ const healthText = health ? health.text : t("common.notChecked");
+ const enabled = !!integration.enabled;
+ const configured = !!integration.configured;
+ return `
+
+ ${escapeHTML(name)}
+ ${escapeHTML(configured ? t("common.configured") : t("common.needsConfig"))} · ${escapeHTML(enabled ? t("common.enabled") : t("common.disabled"))}
+
+ ${escapeHTML(t("common.health"))}: ${escapeHTML(healthText)}
+
+
+
+
+
+
+ `;
+ }).join("");
+}
+
+function renderDiagnostics() {
+ const diagnostics = STATE.diagnostics;
+ if (!diagnostics) {
+ $("#runtime-summary-cards").innerHTML = summaryCard(t("common.loading"), "—", t("common.loading"));
+ $("#stt-summary-list").innerHTML = detailListHtml([[t("common.loading"), t("common.loading")]]);
+ $("#connection-summary-list").innerHTML = detailListHtml([[t("common.loading"), t("common.loading")]]);
+ $("#diagnostic-checks").innerHTML = emptyState(t("common.loading"));
+ return;
+ }
+
+ const runtime = diagnostics.runtime || {};
+ const warmup = runtime.warmup || {};
+ const llm = runtime.llm || {};
+ const stt = diagnostics.stt || {};
+ const connection = diagnostics.connection || {};
+
+ $("#runtime-summary-cards").innerHTML = [
+ summaryCard(uiLabel("mode"), modeLabel(diagnostics.mode || "agent"), heroHeadline()),
+ summaryCard(uiLabel("warmup"), warmupSummary(warmup), warmup.updated_at ? formatDate(warmup.updated_at) : t("common.noData")),
+ summaryCard(uiLabel("llm"), llm.active_provider || llm.configured_provider || t("common.unknown"), llm.active_model || llm.configured_model || t("common.noData")),
+ summaryCard(uiLabel("connection"), connection.connected ? transportLabel(connection.current_transport) : t("common.disconnected"), connection.current_endpoint || connection.last_endpoint || t("common.noData")),
+ ].join("");
+
+ $("#stt-summary-list").innerHTML = detailListHtml([
+ [uiLabel("modelSize"), stt.model_size || t("common.unknown")],
+ [uiLabel("configuredDevice"), stt.configured_device || t("common.unknown")],
+ [uiLabel("resolvedOrder"), arrayOrFallback(stt.resolved_devices)],
+ [uiLabel("deviceInUse"), stt.device_in_use || t("common.unknown")],
+ [uiLabel("language"), stt.language || t("common.unknown")],
+ [uiLabel("runtimeNotes"), arrayOrFallback(stt.notes)],
+ ]);
+
+ $("#connection-summary-list").innerHTML = detailListHtml([
+ [uiLabel("mode"), connection.mode || t("common.unknown")],
+ [uiLabel("priority"), arrayOrFallback(connection.priority)],
+ [uiLabel("connected"), yesNo(connection.connected)],
+ [uiLabel("currentTransport"), transportLabel(connection.current_transport)],
+ [uiLabel("endpoint"), connection.current_endpoint || connection.last_endpoint || t("common.unknown")],
+ [uiLabel("lastConnected"), connection.last_connected_at ? formatDate(connection.last_connected_at) : t("common.noData")],
+ ]);
+
+ const checks = diagnostics.last_checks || {};
+ const names = Object.keys(checks);
+ $("#diagnostic-checks").innerHTML = names.length ? names.map((target) => {
+ const result = checks[target];
+ return `
+
+ ${escapeHTML(target.toUpperCase())}
+ ${escapeHTML(result.summary || t("common.noData"))}
+
+ ${escapeHTML(result.ok ? t("common.connected") : t("common.disconnected"))}
+ ${escapeHTML(result.checked_at ? formatDate(result.checked_at) : t("common.noData"))}
+
+
+ `;
+ }).join("") : emptyState(t("common.noData"));
+}
+
+function summaryCard(label, value, body) {
+ return `
+
+ ${escapeHTML(label)}
+ ${escapeHTML(value)}
+ ${escapeHTML(body)}
+
+ `;
+}
+
+function detailListHtml(rows) {
+ return rows.map(([label, value]) => `
${escapeHTML(label)}${escapeHTML(value)}`).join("");
+}
+
+async function fetchMemoryList(silent = false) {
+ try {
+ const payload = await api("/api/memory/");
+ STATE.memoryFiles = Array.isArray(payload.files) ? payload.files : [];
+ renderMemoryList();
+ } catch (error) {
+ if (!silent) toast(`${t("common.loadFailed")}: ${error.message}`);
+ throw error;
+ }
+}
+
+function renderMemoryList() {
+ const root = $("#memory-list");
+ const files = STATE.memoryFiles || [];
+ if (!files.length) {
+ root.innerHTML = emptyState(t("common.noData"));
+ return;
+ }
+ root.innerHTML = files.map((file) => `
+
+ `).join("");
+}
+
+async function loadMemory(file = STATE.memoryFile, silent = false) {
+ try {
+ const payload = await api(`/api/memory/${encodeURIComponent(file)}`);
+ STATE.memoryFile = payload.filename || file;
+ localStorage.setItem("ccoli.activeMemory", STATE.memoryFile);
+ $("#memory-title").textContent = STATE.memoryFile;
+ $("#memory-meta").textContent = `${formatNumber((payload.content || "").length)} ${uiLabel("chars")}`;
+ $("#memory-editor").value = payload.content || "";
+ renderMemoryList();
+ } catch (error) {
+ if (!silent) toast(`${t("common.loadFailed")}: ${error.message}`);
+ throw error;
+ }
+}
+
+async function saveMemory() {
+ try {
+ const content = $("#memory-editor").value;
+ await api(`/api/memory/${encodeURIComponent(STATE.memoryFile)}`, {
+ method: "PUT",
+ body: JSON.stringify({ content }),
+ });
+ $("#memory-meta").textContent = `${formatNumber(content.length)} ${uiLabel("chars")}`;
+ toast(t("common.saving"));
+ fetchMemoryList(true);
+ } catch (error) {
+ toast(`${t("common.saveFailed")}: ${error.message}`);
+ }
+}
+
+async function fetchSpeakers(silent = false) {
+ try {
+ const payload = await api("/api/conversation/speakers");
+ const speakers = Array.isArray(payload.speakers) ? payload.speakers : [];
+ const select = $("#speaker-filter");
+ const current = select.value;
+ select.innerHTML = `` +
+ speakers.map((speaker) => ``).join("");
+ if (speakers.includes(current)) select.value = current;
+ } catch (error) {
+ if (!silent) toast(`${t("common.loadFailed")}: ${error.message}`);
+ throw error;
+ }
+}
+
+async function fetchConversation(silent = false) {
+ try {
+ const params = new URLSearchParams({ limit: String(Number($("#conversation-limit").value || 50)) });
+ const speaker = $("#speaker-filter").value;
+ if (speaker) params.set("speaker_id", speaker);
+ const payload = await api(`/api/conversation/?${params.toString()}`);
+ STATE.conversations = Array.isArray(payload.history) ? payload.history : [];
+ renderConversation();
+ renderOverviewConversation();
+ } catch (error) {
+ if (!silent) toast(`${t("common.loadFailed")}: ${error.message}`);
+ throw error;
+ }
+}
+
+function renderConversation() {
+ const root = $("#conversation-list");
+ if (!STATE.conversations.length) {
+ root.innerHTML = emptyState(t("common.noData"));
+ return;
+ }
+ root.innerHTML = STATE.conversations.map((item) => {
+ const normalized = normalizeConversation(item);
+ return `
+
+ ${escapeHTML(roleLabel(normalized.role))}
+ ${escapeHTML(normalized.content)}
+ ${escapeHTML(normalized.timestamp ? formatDate(normalized.timestamp) : t("common.unknown"))}
+
+ `;
+ }).join("");
+}
+
+async function clearConversation() {
+ if (!window.confirm(confirmMessage("conversation"))) return;
+ try {
+ await api("/api/conversation/", { method: "DELETE" });
+ STATE.conversations = [];
+ renderConversation();
+ renderOverviewConversation();
+ toast(t("common.historyCleared"));
+ } catch (error) {
+ toast(`${t("common.actionFailed")}: ${error.message}`);
+ }
+}
+
+async function fetchSchedules(silent = false) {
+ try {
+ const payload = await api("/api/schedules/");
+ STATE.schedules = Array.isArray(payload.schedules)
+ ? payload.schedules.slice().sort((a, b) => String(a.datetime || "").localeCompare(String(b.datetime || "")))
+ : [];
+ renderSchedules();
+ renderOverviewSchedules();
+ } catch (error) {
+ if (!silent) toast(`${t("common.loadFailed")}: ${error.message}`);
+ throw error;
+ }
+}
+
+function renderSchedules() {
+ const root = $("#schedule-list");
+ if (!STATE.schedules.length) {
+ root.innerHTML = emptyState(t("common.noData"));
+ return;
+ }
+ root.innerHTML = STATE.schedules.map((schedule) => `
+
+ ${escapeHTML(schedule.title || t("common.unknown"))}
+ ${escapeHTML(formatDate(schedule.datetime))}
+ ${escapeHTML(schedule.description || t("common.noData"))}
+
+ ${escapeHTML(schedule.completed ? t("common.complete") : t("common.active"))}
+ ${escapeHTML(`${formatNumber(schedule.reminder_before || 0)} ${uiLabel("minutes")}`)}
+
+
+ ${schedule.completed ? "" : ``}
+
+
+
+ `).join("");
+}
+
+async function createSchedule(event) {
+ event.preventDefault();
+ try {
+ await api("/api/schedules/", {
+ method: "POST",
+ body: JSON.stringify({
+ title: $("#schedule-title").value.trim(),
+ datetime: $("#schedule-datetime").value,
+ description: $("#schedule-description").value.trim(),
+ reminder_before: Number($("#schedule-reminder").value || 0),
+ }),
+ });
+ $("#schedule-form").reset();
+ seedDate();
+ fetchSchedules(true);
+ toast(t("common.scheduleAdded"));
+ } catch (error) {
+ toast(`${t("common.actionFailed")}: ${error.message}`);
+ }
+}
+
+async function completeSchedule(id) {
+ try {
+ await api(`/api/schedules/${id}/complete`, { method: "POST" });
+ fetchSchedules(true);
+ toast(t("common.scheduleCompleted"));
+ } catch (error) {
+ toast(`${t("common.actionFailed")}: ${error.message}`);
+ }
+}
+
+async function deleteSchedule(id) {
+ if (!window.confirm(confirmMessage("schedule"))) return;
+ try {
+ await api(`/api/schedules/${id}`, { method: "DELETE" });
+ fetchSchedules(true);
+ toast(t("common.scheduleDeleted"));
+ } catch (error) {
+ toast(`${t("common.actionFailed")}: ${error.message}`);
+ }
+}
+
+async function sendChat(event) {
+ event.preventDefault();
+ const speaker = $("#chat-speaker").value.trim() || "web_user";
+ const text = $("#chat-input").value.trim();
+ if (!text) {
+ toast(t("common.messageRequired"));
+ return;
+ }
+ appendChat("user", text, { speaker });
+ $("#chat-input").value = "";
+ try {
+ const payload = await api("/api/chat/", {
+ method: "POST",
+ body: JSON.stringify({ text, speaker_id: speaker }),
+ });
+ STATE.lastChatSignature = `${speaker}::${text}::${payload.response || ""}`;
+ appendChat("assistant", payload.response || "", { emotion: payload.emotion, intent: payload.intent });
+ toast(t("common.chatReceived"));
+ } catch (error) {
+ appendChat("assistant", `Error: ${error.message}`, { emotion: "error" });
+ toast(`${t("common.actionFailed")}: ${error.message}`);
+ }
+}
+
+function appendChat(role, text, extra = {}) {
+ STATE.chat.push({ role, text, timestamp: new Date().toISOString(), ...extra });
+ STATE.chat = STATE.chat.slice(-60);
+ renderChat();
+}
+
+function clearChat() {
+ STATE.chat = [];
+ renderChat();
+}
+
+function renderChat() {
+ const root = $("#chat-feed");
+ if (!STATE.chat.length) {
+ root.innerHTML = emptyState(t("common.noData"));
+ return;
+ }
+ root.innerHTML = STATE.chat.map((message) => `
+
+ ${escapeHTML(message.text)}
+ ${escapeHTML(`${roleLabel(message.role)} · ${formatDate(message.timestamp)}`)}
+
+ `).join("");
+ root.scrollTop = root.scrollHeight;
+}
+
+async function fetchLogs(silent = false) {
+ try {
+ const payload = await api(`/api/logs/?tail=${Number($("#log-tail").value || 150)}`);
+ STATE.logs = Array.isArray(payload.lines) ? payload.lines.slice(-500) : [];
+ renderLogs();
+ renderOverviewLogs();
+ } catch (error) {
+ if (!silent) toast(`${t("common.loadFailed")}: ${error.message}`);
+ throw error;
+ }
+}
+
+function renderLogs() {
+ const filter = ($("#log-filter").value || "").trim().toLowerCase();
+ const lines = filter ? STATE.logs.filter((line) => line.toLowerCase().includes(filter)) : STATE.logs;
+ $("#logs-view").textContent = lines.length ? lines.join("\n") : t("common.noData");
+ $("#logs-pause").textContent = STATE.logsPaused ? t("common.resume") : t("common.pause");
+}
+
+function toggleLogs() {
+ STATE.logsPaused = !STATE.logsPaused;
+ renderLogs();
+}
+
+function renderOverviewSchedules() {
+ const items = STATE.schedules.filter((schedule) => !schedule.completed).slice(0, 4);
+ $("#overview-schedules").innerHTML = items.length ? items.map((schedule) => `
+
+ ${escapeHTML(schedule.title || t("common.unknown"))}
+ ${escapeHTML(formatDate(schedule.datetime))}
+
+ `).join("") : emptyState(t("common.noData"));
+}
+
+function renderOverviewConversation() {
+ const items = STATE.conversations.slice(-4).reverse();
+ $("#overview-conversation").innerHTML = items.length ? items.map((item) => {
+ const normalized = normalizeConversation(item);
+ return `
+
+ ${escapeHTML(roleLabel(normalized.role))}
+ ${escapeHTML(truncate(normalized.content, 88))}
+
+ `;
+ }).join("") : emptyState(t("common.noData"));
+}
+
+function renderOverviewLogs() {
+ const items = STATE.logs.slice(-4).reverse();
+ $("#overview-logs").innerHTML = items.length ? items.map((line) => `
+
+ ${escapeHTML(truncate(line, 110))}
+
+ `).join("") : emptyState(t("common.noData"));
+}
+
+function renderStatus() {
+ const status = STATE.status || {};
+ const diagnostics = STATE.diagnostics || {};
+ const connection = diagnostics.connection || {};
+ const mode = status.mode || "agent";
+ const emotion = status.emotion || "neutral";
+ const conversationCount = Number(status.conversation_count || 0);
+
+ $("#mode-chip").textContent = `${uiLabel("mode")} · ${modeLabel(mode)}`;
+ $("#emotion-chip").textContent = `${uiLabel("emotion")} · ${emotionLabel(emotion)}`;
+ $("#conversation-chip").textContent = `${formatNumber(conversationCount)} ${uiLabel("turns")}`;
+ $("#sidebar-summary").textContent = sidebarSummary(status, connection);
+
+ $("#hero-headline").textContent = heroHeadline();
+ $("#hero-description").textContent = heroDescription(status, connection);
+ $("#hero-note").textContent = heroNote();
+
+ $("#metric-mode").textContent = modeLabel(mode);
+ $("#metric-emotion").textContent = emotionLabel(emotion);
+ $("#metric-conversation").textContent = formatNumber(conversationCount);
+ $("#metric-connection").textContent = connection.connected ? transportLabel(connection.current_transport) : t("common.disconnected");
+}
+
+function sidebarSummary(status, connection) {
+ const proactive = status.proactive || {};
+ if (connection.connected) {
+ return `${transportLabel(connection.current_transport)} · ${connection.current_endpoint || t("common.connected")}`;
+ }
+ if (proactive && proactive.sleep_mode) {
+ return localePhrase(
+ `Sleep until ${formatDate(proactive.sleep_until)}`,
+ `${formatDate(proactive.sleep_until)}까지 수면`,
+ `${formatDate(proactive.sleep_until)} までスリープ`,
+ `休眠至 ${formatDate(proactive.sleep_until)}`
+ );
+ }
+ return t("brand.body");
+}
+
+function heroHeadline() {
+ const mode = STATE.status?.mode || STATE.diagnostics?.mode || "agent";
+ const connection = STATE.diagnostics?.connection || {};
+ if (mode === "robot") return localePhrase("Robot mode is live", "로봇 모드가 동작 중입니다", "ロボットモードが動作中です", "机器人模式正在运行");
+ if (connection.connected) return localePhrase("Ready for the next turn", "다음 대화를 받을 준비가 됐습니다", "次の会話の準備ができています", "已准备好接收下一轮对话");
+ return localePhrase("Waiting for the device", "디바이스 연결을 기다리고 있습니다", "デバイス接続を待っています", "正在等待设备连接");
+}
+
+function heroDescription(status, connection) {
+ const diagnostics = STATE.diagnostics || {};
+ const stt = diagnostics.stt || {};
+ if (connection.connected) {
+ return localePhrase(
+ `Current transport is ${transportLabel(connection.current_transport)} and STT is configured for ${stt.configured_device || t("common.unknown")}.`,
+ `현재 ${transportLabel(connection.current_transport)} 경로로 연결되어 있고, STT는 ${stt.configured_device || t("common.unknown")} 기준으로 설정되어 있습니다.`,
+ `現在の接続は ${transportLabel(connection.current_transport)} で、STT は ${stt.configured_device || t("common.unknown")} 基準で設定されています。`,
+ `当前通过 ${transportLabel(connection.current_transport)} 连接,STT 已按 ${stt.configured_device || t("common.unknown")} 配置。`
+ );
+ }
+ if (status.mode === "robot") {
+ return localePhrase(
+ "The layout keeps long explanations in the body text while the hero headline stays compact.",
+ "긴 설명은 본문으로 보내고, hero 제목은 짧게 유지합니다.",
+ "長い説明は本文に置き、hero 見出しは短く保ちます。",
+ "较长说明放在正文中,hero 标题保持简短。"
+ );
+ }
+ return localePhrase(
+ "The interface can switch locale without changing runtime language defaults.",
+ "화면 언어는 바뀌어도 런타임 언어 기본값은 그대로 유지됩니다.",
+ "UI 言語を切り替えてもランタイム言語の既定値は変わりません。",
+ "切换界面语言不会改变运行时语言默认值。"
+ );
+}
+
+function heroNote() {
+ const warmup = STATE.diagnostics?.runtime?.warmup || {};
+ const connection = STATE.diagnostics?.connection || {};
+ const parts = [
+ warmup.stt_ready ? uiLabel("sttReady") : uiLabel("sttWaiting"),
+ warmup.tts_ready ? uiLabel("ttsReady") : uiLabel("ttsWaiting"),
+ connection.last_connected_at ? formatDate(connection.last_connected_at) : t("common.noData"),
+ ];
+ return parts.join(" · ");
+}
+
+function saveToken() {
+ STATE.token = $("#auth-token").value.trim();
+ if (STATE.token) {
+ localStorage.setItem("ccoli.authToken", STATE.token);
+ } else {
+ localStorage.removeItem("ccoli.authToken");
+ }
+ toast(t("common.tokenSaved"));
+}
+
+function clearToken() {
+ STATE.token = "";
+ $("#auth-token").value = "";
+ localStorage.removeItem("ccoli.authToken");
+ toast(t("common.tokenCleared"));
+}
+
+function connectWs(force = false) {
+ if (force && STATE.ws) {
+ clearWsTimers();
+ try {
+ STATE.ws.close();
+ } catch (error) {
+ console.warn(error);
+ }
+ }
+
+ if (STATE.ws && (STATE.ws.readyState === WebSocket.OPEN || STATE.ws.readyState === WebSocket.CONNECTING)) return;
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+ wsState(false, localePhrase("Connecting live...", "실시간 연결 중...", "ライブ接続中...", "正在连接实时流..."));
+
+ try {
+ STATE.ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
+ } catch (error) {
+ wsState(false, localePhrase("Live connection failed", "실시간 연결 실패", "ライブ接続失敗", "实时连接失败"));
+ toast(`${t("common.actionFailed")}: ${error.message}`);
+ retryWs();
+ return;
+ }
+
+ STATE.ws.addEventListener("open", () => {
+ wsState(true, localePhrase("Live connected", "실시간 연결됨", "ライブ接続済み", "实时连接成功"));
+ clearWsTimers();
+ STATE.wsHeartbeat = window.setInterval(() => {
+ if (STATE.ws && STATE.ws.readyState === WebSocket.OPEN) STATE.ws.send("ping");
+ }, 20000);
+ });
+
+ STATE.ws.addEventListener("message", (event) => {
+ try {
+ handleWs(JSON.parse(event.data));
+ } catch (error) {
+ console.warn("Invalid websocket payload", error);
+ }
+ });
+
+ STATE.ws.addEventListener("close", () => {
+ wsState(false, localePhrase("Reconnecting live...", "실시간 재연결 대기 중", "ライブ再接続待機中", "等待重新连接实时流"));
+ clearWsTimers();
+ retryWs();
+ });
+
+ STATE.ws.addEventListener("error", () => {
+ wsState(false, localePhrase("Live connection unstable", "실시간 연결 불안정", "ライブ接続が不安定です", "实时连接不稳定"));
+ });
+}
+
+function handleWs(payload) {
+ if (!payload || typeof payload !== "object") return;
+ if (payload.event === "emotion_change") {
+ STATE.status = { ...(STATE.status || {}), emotion: payload.emotion };
+ renderStatus();
+ return;
+ }
+ if (payload.event === "status_update") {
+ STATE.status = { ...(STATE.status || {}), ...payload };
+ renderStatus();
+ return;
+ }
+ if (payload.event === "log" && payload.line) {
+ STATE.logs.push(payload.line);
+ STATE.logs = STATE.logs.slice(-500);
+ if (!STATE.logsPaused) renderLogs();
+ renderOverviewLogs();
+ return;
+ }
+ if (payload.event === "chat_response") {
+ const signature = `${payload.speaker_id || ""}::${payload.text || ""}::${payload.response || ""}`;
+ if (signature === STATE.lastChatSignature) return;
+ STATE.lastChatSignature = signature;
+ if (payload.text) appendChat("user", payload.text, { speaker: payload.speaker_id || "web_user" });
+ if (payload.response) appendChat("assistant", payload.response, { emotion: payload.emotion, intent: payload.intent });
+ }
+}
+
+function wsState(live, label) {
+ $("#ws-dot").classList.toggle("live", live);
+ $("#ws-label").textContent = label;
+}
+
+function retryWs() {
+ if (!STATE.wsRetry) {
+ STATE.wsRetry = window.setTimeout(() => {
+ STATE.wsRetry = null;
+ connectWs();
+ }, 3000);
+ }
+}
+
+function clearWsTimers() {
+ if (STATE.wsHeartbeat) {
+ window.clearInterval(STATE.wsHeartbeat);
+ STATE.wsHeartbeat = null;
+ }
+ if (STATE.wsRetry) {
+ window.clearTimeout(STATE.wsRetry);
+ STATE.wsRetry = null;
+ }
+}
+
+function toast(message) {
+ const node = document.createElement("div");
+ node.className = "toast";
+ node.textContent = message;
+ $("#toasts").appendChild(node);
+ window.setTimeout(() => node.remove(), 2600);
+}
+
+function normalizeConversation(item) {
+ if (typeof item === "string") return { role: "user", content: item, timestamp: "", emotion: "" };
+ return {
+ role: item?.role || "system",
+ content: item?.content || item?.text || JSON.stringify(item || ""),
+ timestamp: item?.timestamp || item?.created_at || "",
+ emotion: item?.emotion || "",
+ };
+}
+
+function roleLabel(role) {
+ const labels = {
+ user: localePhrase("User", "사용자", "ユーザー", "用户"),
+ assistant: "ccoli",
+ system: localePhrase("System", "시스템", "システム", "系统"),
+ };
+ return labels[role] || role;
+}
+
+function modeLabel(mode) {
+ const labels = {
+ agent: localePhrase("Agent", "에이전트", "エージェント", "代理模式"),
+ robot: localePhrase("Robot", "로봇", "ロボット", "机器人"),
+ };
+ return labels[mode] || mode || t("common.unknown");
+}
+
+function transportLabel(transport) {
+ const labels = {
+ auto: localePhrase("Auto", "자동", "自動", "自动"),
+ wired: localePhrase("Wired", "유선", "有線", "有线"),
+ wifi: "Wi-Fi",
+ };
+ return labels[transport] || transport || t("common.unknown");
+}
+
+function emotionLabel(emotion) {
+ const labels = {
+ neutral: localePhrase("Neutral", "차분함", "ニュートラル", "平静"),
+ happy: localePhrase("Happy", "기쁨", "喜び", "愉快"),
+ excited: localePhrase("Excited", "들뜸", "高揚", "兴奋"),
+ calm: localePhrase("Calm", "안정", "落ち着き", "平稳"),
+ sad: localePhrase("Sad", "가라앉음", "悲しみ", "低落"),
+ angry: localePhrase("Angry", "예민함", "怒り", "愤怒"),
+ surprised: localePhrase("Surprised", "놀람", "驚き", "惊讶"),
+ worried: localePhrase("Worried", "걱정", "心配", "担忧"),
+ };
+ return labels[emotion] || emotion || t("common.unknown");
+}
+
+function localePhrase(en, ko, ja, zh) {
+ return { en, ko, ja, zh }[STATE.locale] || en;
+}
+
+function uiLabel(key) {
+ const labels = {
+ mode: localePhrase("Mode", "모드", "モード", "模式"),
+ emotion: localePhrase("Emotion", "감정", "感情", "情绪"),
+ turns: localePhrase("turns", "회", "件", "轮"),
+ warmup: localePhrase("Warmup", "워밍업", "ウォームアップ", "预热"),
+ llm: "LLM",
+ connection: localePhrase("Connection", "연결", "接続", "连接"),
+ modelSize: localePhrase("Model size", "모델 크기", "モデルサイズ", "模型大小"),
+ configuredDevice: localePhrase("Configured device", "설정 디바이스", "設定デバイス", "配置设备"),
+ resolvedOrder: localePhrase("Resolved order", "해결 순서", "解決順序", "解析顺序"),
+ deviceInUse: localePhrase("Device in use", "사용 중 디바이스", "使用中デバイス", "当前设备"),
+ language: localePhrase("Language", "언어", "言語", "语言"),
+ runtimeNotes: localePhrase("Runtime notes", "런타임 메모", "ランタイムメモ", "运行时说明"),
+ priority: localePhrase("Priority", "우선순위", "優先順位", "优先级"),
+ connected: localePhrase("Connected", "연결 여부", "接続状態", "连接状态"),
+ currentTransport: localePhrase("Current transport", "현재 연결 경로", "現在の経路", "当前链路"),
+ endpoint: localePhrase("Endpoint", "엔드포인트", "エンドポイント", "端点"),
+ lastConnected: localePhrase("Last connected", "마지막 연결 시각", "最終接続", "最近连接时间"),
+ chars: localePhrase("chars", "자", "文字", "字"),
+ minutes: localePhrase("min", "분", "分", "分钟"),
+ sttReady: localePhrase("STT ready", "STT 준비 완료", "STT 準備完了", "STT 已就绪"),
+ sttWaiting: localePhrase("STT waiting", "STT 대기 중", "STT 待機中", "STT 等待中"),
+ ttsReady: localePhrase("TTS ready", "TTS 준비 완료", "TTS 準備完了", "TTS 已就绪"),
+ ttsWaiting: localePhrase("TTS waiting", "TTS 대기 중", "TTS 待機中", "TTS 等待中"),
+ };
+ return labels[key] || key;
+}
+
+function warmupSummary(warmup) {
+ const stt = warmup.stt_ready ? uiLabel("sttReady") : uiLabel("sttWaiting");
+ const tts = warmup.tts_ready ? uiLabel("ttsReady") : uiLabel("ttsWaiting");
+ return `${stt} · ${tts}`;
+}
+
+function yesNo(value) {
+ return value ? t("common.yes") : t("common.no");
+}
+
+function arrayOrFallback(value) {
+ return Array.isArray(value) && value.length ? value.join(" > ") : t("common.noData");
+}
+
+function emptyState(text) {
+ return `${escapeHTML(text)}
`;
+}
+
+function formatDate(value) {
+ if (!value) return t("common.unknown");
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return String(value);
+ return new Intl.DateTimeFormat(uiLocale(), { dateStyle: "medium", timeStyle: "short" }).format(date);
+}
+
+function formatNumber(value) {
+ return new Intl.NumberFormat(uiLocale()).format(Number(value || 0));
+}
+
+function truncate(value, maxLength) {
+ const text = String(value || "");
+ return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
+}
+
+function seedDate() {
+ const date = new Date();
+ date.setMinutes(0, 0, 0);
+ date.setHours(date.getHours() + 1);
+ $("#schedule-datetime").value = localDate(date);
+}
+
+function localDate(date) {
+ const pad = (value) => String(value).padStart(2, "0");
+ return [
+ date.getFullYear(),
+ "-",
+ pad(date.getMonth() + 1),
+ "-",
+ pad(date.getDate()),
+ "T",
+ pad(date.getHours()),
+ ":",
+ pad(date.getMinutes()),
+ ].join("");
+}
+
+function confirmMessage(type) {
+ if (type === "conversation") {
+ return localePhrase(
+ "Clear the conversation history?",
+ "대화 히스토리를 비울까요?",
+ "会話履歴を削除しますか?",
+ "要清空对话历史吗?"
+ );
+ }
+ return localePhrase(
+ "Delete this schedule?",
+ "이 일정을 삭제할까요?",
+ "この予定を削除しますか?",
+ "要删除这个日程吗?"
+ );
+}
+
+function escapeHTML(value) {
+ return String(value ?? "")
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
diff --git a/server/web/static/index.html b/server/web/static/index.html
index d8d6f20..0c6d45b 100644
--- a/server/web/static/index.html
+++ b/server/web/static/index.html
@@ -1,93 +1,455 @@
-
-
+
+
ccoli dashboard
-
+
+
-
-
-
-
-
- Live Overview
콜리는 지금 차분하게 다음 대화를 기다리고 있습니다.
브랜드 톤과 실시간 상태를 한 화면에서 자연스럽게 연결했습니다.
최근 상태를 불러오는 중입니다.

Brand Language
브랜드 배너의 초록, 민트, 아쿠아 포인트를 그대로 가져왔습니다.

둥근 실루엣과 차분한 광택을 라운드, 강조선, 상태색에 반영했습니다.
- 현재 감정최근 기록 없음
운영 모드대화 중심
누적 턴 수기록 대기
선제 상호작용최근 활동 없음
-
-
-
- Memory Files
콜리의 기억을 직접 관리합니다.
파일 목록
버튼을 누르면 우측 편집기로 로드됩니다.
- Conversation History
대화 히스토리와 화자별 맥락을 살펴봅니다.
- Schedules
일정과 리마인더를 같은 화면에서 추가하고 관리합니다.
- Web Chat
브라우저에서 바로 콜리와 대화합니다.
메시지 보내기
웹 채팅은 `/api/chat`을 사용합니다.
- 브라우저 로컬 스토리지에만 저장됩니다.
Quick Patch
config.yaml 값 빠르게 수정
설정을 불러오는 중입니다.
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-