diff --git a/wsai/backends/claude.py b/wsai/backends/claude.py index acbf4c4..c6ca75f 100644 --- a/wsai/backends/claude.py +++ b/wsai/backends/claude.py @@ -24,6 +24,7 @@ import os import time from ..interfaces import Frame, Reply, ScreenObservation +from ..prompt_store import get_persona # Claude Code OAuth tokens only answer when the first system block is exactly # this identity string; the real persona/instructions go in later blocks. @@ -148,10 +149,12 @@ class ClaudeBrain: screen_note = f"[지금 화면] {screen.text}\n\n" if screen else "[지금 화면] (아직 못 읽음)\n\n" msgs.append({"role": "user", "content": screen_note + user_text}) client = self._auth.client() + # Read the persona live each turn so a dashboard edit applies immediately + # (falls back to the built-in PERSONA when no override is saved). resp = await client.messages.create( model=self.model, max_tokens=400, - system=self._auth.system(self.PERSONA), + system=self._auth.system(get_persona(self.PERSONA)), messages=msgs, ) text = "".join(b.text for b in resp.content if b.type == "text") diff --git a/wsai/dashboard.py b/wsai/dashboard.py index 5476999..61bd437 100644 --- a/wsai/dashboard.py +++ b/wsai/dashboard.py @@ -58,6 +58,8 @@ def _make_handler(dash: "Dashboard"): elif path == "/api/state": body = json.dumps(monitor.snapshot(), ensure_ascii=False).encode("utf-8") self._send(200, body, "application/json; charset=utf-8") + elif path == "/api/prompt": + self._handle_prompt_get() elif path == "/events": self._stream_events() else: @@ -69,6 +71,8 @@ def _make_handler(dash: "Dashboard"): self._handle_stt() elif path == "/api/voice-turn": self._handle_voice_turn() + elif path == "/api/prompt": + self._handle_prompt_post() else: self._send(404, b"not found", "text/plain; charset=utf-8") @@ -138,6 +142,49 @@ def _make_handler(dash: "Dashboard"): ensure_ascii=False).encode("utf-8") self._send(500, body, "application/json; charset=utf-8") + def _default_persona(self) -> str: + # The built-in seed prompt, used when no override is saved. Imported + # lazily so the dashboard has no hard dependency on the Claude backend. + try: + from .backends.claude import ClaudeBrain + return ClaudeBrain.PERSONA + except Exception: + return "" + + def _handle_prompt_get(self) -> None: + """Return the live bot system prompt so the page can show/edit it.""" + from . import prompt_store + default = self._default_persona() + body = json.dumps({ + "ok": True, + "prompt": prompt_store.get_persona(default), + "default": default, + "overridden": prompt_store.is_overridden(), + }, ensure_ascii=False).encode("utf-8") + self._send(200, body, "application/json; charset=utf-8") + + def _handle_prompt_post(self) -> None: + """Save an edited system prompt; takes effect on the next reply. An + empty prompt clears the override and reverts to the built-in default.""" + from . import prompt_store + raw = self._read_body() + try: + data = json.loads(raw.decode("utf-8")) if raw else {} + prompt = data.get("prompt", "") + except (ValueError, AttributeError): + self._send(400, json.dumps({"ok": False, "error": "invalid JSON"}).encode(), + "application/json; charset=utf-8") + return + prompt_store.set_persona(prompt) + monitor.log("info", "봇 프롬프트가 수정되었습니다" if prompt.strip() + else "봇 프롬프트가 기본값으로 초기화되었습니다") + body = json.dumps({ + "ok": True, + "prompt": prompt_store.get_persona(self._default_persona()), + "overridden": prompt_store.is_overridden(), + }, ensure_ascii=False).encode("utf-8") + self._send(200, body, "application/json; charset=utf-8") + def _stream_events(self) -> None: self.send_response(200) self.send_header("Content-Type", "text/event-stream; charset=utf-8") @@ -456,6 +503,28 @@ PAGE = r""" .sttres{margin-top:12px;font-size:15px;min-height:1px} .sttres .txt{color:var(--heard);font-weight:600;line-height:1.5} .sttres .meta{color:var(--muted);font-size:12px;margin-top:5px} + .hbtn{padding:6px 12px;font-size:12.5px} + /* Modal / popup (reused by 프롬프트, 화이트/블랙리스트 …) */ + .modal{position:fixed;inset:0;z-index:20;background:rgba(4,7,11,.66); + display:flex;align-items:center;justify-content:center;padding:20px} + .modal-card{background:var(--panel);border:1px solid var(--line);border-radius:16px; + width:min(760px,100%);max-height:86vh;display:flex;flex-direction:column;overflow:hidden; + box-shadow:0 24px 60px rgba(0,0,0,.5)} + .modal-head{display:flex;align-items:center;gap:12px;padding:12px 16px;border-bottom:1px solid var(--line)} + .modal-title{font-size:14px;font-weight:650} + .modal-actions{margin-left:auto;display:flex;gap:8px} + .modal-body{padding:16px;overflow:auto} + .modal-body textarea{width:100%;min-height:340px;background:var(--panel2);color:var(--fg); + border:1px solid var(--line);border-radius:10px;padding:12px;font-size:13px;line-height:1.6; + font-family:inherit;resize:vertical} + .modal-body textarea[readonly]{color:var(--muted)} + .modal-note{color:var(--muted);font-size:12px;margin:0 0 10px} + .btn.primary{background:#16452c;border-color:#1f5236;color:#9ff0bd} + .btn.primary:hover{background:#1b5636} + .toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:30; + background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:10px 16px; + font-size:13px;box-shadow:0 10px 30px rgba(0,0,0,.4);opacity:0;transition:opacity .2s} + .toast.show{opacity:1}
@@ -465,6 +534,7 @@ PAGE = r"""