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:
@@ -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")
|
||||
|
||||
@@ -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
54
wsai/prompt_store.py
Normal 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
|
||||
Reference in New Issue
Block a user