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

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