feat(dashboard): live-editable bot prompt via popup (view/edit/save)

Adds a persisted, runtime-editable system prompt. The brain reads the persona
on every turn (prompt_store.get_persona), so a dashboard edit applies to the
next reply with no restart; blank clears the override back to the built-in
PERSONA. New endpoints GET/POST /api/prompt, and a reusable modal popup
(뒤로가기 + 수정/저장) that later white/blacklist features will share.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-08-22 11:20:57 +09:00
parent 1cb7658290
commit df07224feb
3 changed files with 181 additions and 1 deletions

View File

@@ -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")

View File

@@ -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"""<!DOCTYPE html>
.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}
</style>
</head>
<body>
@@ -465,6 +534,7 @@ PAGE = r"""<!DOCTYPE html>
<div class="sub">STT → 두뇌 → TTS 음성 루프를 단계별로 관찰</div>
</div>
<div class="pill"><span id="dot" class="dot off"></span><span id="listen">연결 대기</span></div>
<button id="promptBtn" class="btn hbtn">📝 프롬프트</button>
<div class="stats">
<div class="stat"><b id="s-turns">0</b><span>대화 수</span></div>
<div class="stat"><b id="s-errors">0</b><span>오류</span></div>
@@ -492,6 +562,17 @@ PAGE = r"""<!DOCTYPE html>
<div id="events"></div>
</div>
</main>
<div id="modal" class="modal" style="display:none">
<div class="modal-card">
<div class="modal-head">
<button class="btn back" id="modalBack">← 뒤로</button>
<span class="modal-title" id="modalTitle"></span>
<span class="modal-actions" id="modalActions"></span>
</div>
<div class="modal-body" id="modalBody"></div>
</div>
</div>
<div id="toast" class="toast"></div>
<script>
const $ = (id)=>document.getElementById(id);
const turns = new Map(); // id -> turn object
@@ -665,6 +746,48 @@ async function sendBlob(blob){
};
})();
// --- Reusable popup/modal (프롬프트, 화이트/블랙리스트 등이 공유) ---------- #
function openModal(title, actionsHtml){
$('modalTitle').textContent = title;
$('modalActions').innerHTML = actionsHtml || '';
$('modal').style.display = 'flex';
}
function closeModal(){ $('modal').style.display='none'; $('modalBody').innerHTML=''; $('modalActions').innerHTML=''; }
$('modalBack').onclick = closeModal;
$('modal').addEventListener('click', (e)=>{ if(e.target===$('modal')) closeModal(); });
document.addEventListener('keydown', (e)=>{ if(e.key==='Escape' && $('modal').style.display==='flex') closeModal(); });
let toastTimer=null;
function toast(msg){
const t=$('toast'); t.textContent=msg; t.classList.add('show');
clearTimeout(toastTimer); toastTimer=setTimeout(()=>t.classList.remove('show'), 2200);
}
// --- 프롬프트: 현재 시스템 프롬프트 보기/수정/저장 ------------------------- #
async function openPrompt(){
openModal('봇 프롬프트', '<button class="btn" id="pEdit">수정</button>'
+ '<button class="btn primary" id="pSave" style="display:none">저장</button>');
$('modalBody').innerHTML = '<p class="modal-note" id="pNote">현재 봇의 시스템 프롬프트입니다. 저장하면 다음 답변부터 즉시 적용됩니다. (빈칸 저장 시 기본값 복원)</p>'
+ '<textarea id="pText" readonly>불러오는 중…</textarea>';
let data={};
try{ data=await (await fetch('/api/prompt')).json(); }catch(e){ $('pText').value='불러오기 실패: '+e; return; }
$('pText').value = data.prompt || '';
$('pNote').textContent = (data.overridden ? '현재 사용자 지정 프롬프트가 적용 중입니다. ' : '현재 기본 프롬프트가 적용 중입니다. ')
+ '저장하면 다음 답변부터 즉시 적용됩니다. (빈칸 저장 시 기본값 복원)';
$('pEdit').onclick = ()=>{ $('pText').removeAttribute('readonly'); $('pText').focus(); $('pEdit').style.display='none'; $('pSave').style.display='inline-block'; };
$('pSave').onclick = async ()=>{
$('pSave').disabled=true;
try{
const r=await fetch('/api/prompt',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({prompt:$('pText').value})});
const j=await r.json();
if(j.ok){ toast('프롬프트 저장됨 · 다음 답변부터 적용'); closeModal(); }
else { toast('저장 실패: '+(j.error||'')); }
}catch(e){ toast('저장 실패: '+e); }
finally{ $('pSave').disabled=false; }
};
}
$('promptBtn').onclick = openPrompt;
connect();
// Refresh uptime label every second from the last known status.
setInterval(()=>{ if(statusData){ statusData.uptime_s=(statusData.uptime_s||0)+1; $('s-up').textContent=fmtUptime(statusData.uptime_s);} }, 1000);

54
wsai/prompt_store.py Normal file
View File

@@ -0,0 +1,54 @@
"""Persisted, live-editable bot persona (the brain's system prompt).
The dashboard lets the user view and edit the bot's system prompt at runtime.
The brain reads the current persona on every turn, so an edit takes effect on
the next reply with no restart. The text is persisted to disk so it survives a
restart; when no override file exists the caller's default
(``ClaudeBrain.PERSONA``) is used.
Path: ``$WSAI_PROMPT_PATH`` or ``~/.config/wsai/persona.txt`` (disk, never a
RAM-backed tmpfs).
"""
from __future__ import annotations
import os
import threading
from pathlib import Path
_LOCK = threading.Lock()
def _path() -> Path:
override = os.environ.get("WSAI_PROMPT_PATH")
return Path(override) if override else (Path.home() / ".config" / "wsai" / "persona.txt")
def get_persona(default: str) -> str:
"""Return the saved persona override, or ``default`` if none is set."""
try:
with _LOCK:
text = _path().read_text(encoding="utf-8")
except OSError:
return default
return text if text.strip() else default
def set_persona(text: str) -> None:
"""Persist a new persona. Blank text clears the override (reverts to default)."""
p = _path()
with _LOCK:
p.parent.mkdir(parents=True, exist_ok=True)
if text and text.strip():
p.write_text(text, encoding="utf-8")
else:
p.unlink(missing_ok=True)
def is_overridden() -> bool:
"""True when a non-empty override file is in effect."""
try:
with _LOCK:
return bool(_path().read_text(encoding="utf-8").strip())
except OSError:
return False