From e7dae494059dbd91aaa9ccc48a71dcee0706a668 Mon Sep 17 00:00:00 2001 From: bongkj Date: Tue, 24 Mar 2026 14:35:20 +0900 Subject: [PATCH] feat: redesign web dashboard with multilingual diagnostics --- QUICKSTART.md | 2 +- README.md | 2 +- docs/AGENT_FEATURE_PLANNING.md | 13 + docs/PRD.md | 5 + server/tests/test_web_dashboard_static.py | 33 +- server/tests/test_web_runtime_routes.py | 108 +- server/web/app.py | 21 +- server/web/routes/api_diagnostics.py | 172 ++ server/web/static/dashboard.css | 328 ++++ server/web/static/dashboard.js | 1875 +++++++++++++++++++++ server/web/static/index.html | 526 +++++- 11 files changed, 2984 insertions(+), 101 deletions(-) create mode 100644 server/web/routes/api_diagnostics.py create mode 100644 server/web/static/dashboard.css create mode 100644 server/web/static/dashboard.js 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 - + + -
- -
-

Apple-Inspired Glassmorphism

콜리의 상태, 기억, 대화를 맑고 조용한 흐름으로 관리합니다.

배경보다 여백, 장식보다 깊이, 기능보다 흐름을 먼저 놓고 다시 구성했습니다.

-
-

Live Overview

콜리는 지금 차분하게 다음 대화를 기다리고 있습니다.

브랜드 톤과 실시간 상태를 한 화면에서 자연스럽게 연결했습니다.

최근 상태를 불러오는 중입니다.
ccoli mascot card

Brand Language

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

ccoli logo artwork

둥근 실루엣과 차분한 광택을 라운드, 강조선, 상태색에 반영했습니다.

-

Current Emotion

neutral
현재 감정최근 기록 없음

Mode

agent
운영 모드대화 중심

Conversation

0
누적 턴 수기록 대기

Proactive

대기
선제 상호작용최근 활동 없음
-

다가오는 일정

오늘과 앞으로의 리마인더

최근 대화

마지막 대화 흐름

라이브 로그

웹소켓 최신 이벤트

-
- -

Memory Files

콜리의 기억을 직접 관리합니다.

파일 목록

버튼을 누르면 우측 편집기로 로드됩니다.

Soul.md

파일을 불러오는 중입니다.

-

Conversation History

대화 히스토리와 화자별 맥락을 살펴봅니다.

-

Schedules

일정과 리마인더를 같은 화면에서 추가하고 관리합니다.

ISO 8601 형식으로 서버에 저장됩니다.
-

Web Chat

브라우저에서 바로 콜리와 대화합니다.

메시지 보내기

웹 채팅은 `/api/chat`을 사용합니다.

-

Auth

웹 인증 토큰

브라우저 로컬 스토리지에만 저장됩니다.

Quick Patch

config.yaml 값 빠르게 수정

Config Snapshot

현재 서버 설정

설정을 불러오는 중입니다.

Integrations

외부 연동 상태

-

Logs

실시간 로그 스트림

로그를 불러오는 중입니다.
+
+ + +
+
+
+

+

+

+
+
+ +
+ + +
+
+
+ +
+
+
+
+

+

+

+
+
+ + +
+
+
+
+ ccoli broccoli mascot +
+
+ +
+
+

+
+
+
+

+
+
+
+

+
+
+
+

+
+
+
+ +
+
+
+
+

+

+
+ +
+
+
+ +
+
+
+

+

+
+ +
+
+
+ +
+
+
+

+

+
+ +
+
+
+
+
+ +
+
+
+
+

+

+

+
+ +
+
+ +
+ +
+
+
+
+

+

+
+ +
+
+
+ + +
+ +
+
+
+ +
+
+
+

+

+
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
+
+

+

+
+ +
+
+
+ +
+
+
+

+

+
+ +
+
+
+
+ +
+
+
+

+

+
+
+
+
+ +
+ + + +
+
+

+          
+
+
+ +
+
+
+
+

+

+

+
+
+ + +
+
+
+
+
+
+

+

+
+
+
+
+
+
+
+

+

+
+
+ +
+
+
+
+ +
+
+
+
+

+

+

+
+
+ + +
+
+
+ + +
+
+
+
+ +
+
+
+
+

+

+

+
+ +
+
+
+ + + + +
+
+ +
+
+
+
+
+ +
+
+
+
+
+

+

+

+
+
+
+
+
+
+
+

+

+
+
+
+ + +
+ + +
+
+
+
+
+ +
+
+
+
+

+

+

+
+
+ + +
+
+
+ + +
+

+        
+
+
-