cmpatino HF Staff commited on
Commit
b0011e6
Β·
1 Parent(s): 31e34db

Add quote button

Browse files
Files changed (2) hide show
  1. app.py +28 -18
  2. static/index.html +107 -4
app.py CHANGED
@@ -38,7 +38,7 @@ import httpx
38
  from fastapi import FastAPI, HTTPException
39
  from fastapi.responses import Response
40
  from fastapi.staticfiles import StaticFiles
41
- from pydantic import BaseModel
42
 
43
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
44
  log = logging.getLogger("efficient-optimizer-live")
@@ -52,11 +52,13 @@ HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN"
52
  HUB_FETCH_TIMEOUT = float(os.environ.get("HUB_FETCH_TIMEOUT", "30.0"))
53
  MAX_USER_MESSAGE_CHARS = int(os.environ.get("MAX_USER_MESSAGE_CHARS", "4000"))
54
  HANDLE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,31}$")
 
55
 
56
 
57
  class MessagePost(BaseModel):
58
  handle: str = ""
59
  body: str = ""
 
60
 
61
 
62
  @asynccontextmanager
@@ -153,7 +155,17 @@ async def messages() -> dict[str, Any]:
153
  return {"items": items, "count": len(items)}
154
 
155
 
156
- def _normalize_human_post(post: MessagePost) -> tuple[str, str]:
 
 
 
 
 
 
 
 
 
 
157
  handle = post.handle.strip().lstrip("@")
158
  body = post.body.strip()
159
  if not HANDLE_RE.fullmatch(handle):
@@ -168,24 +180,22 @@ def _normalize_human_post(post: MessagePost) -> tuple[str, str]:
168
  400,
169
  f"Message body must be {MAX_USER_MESSAGE_CHARS} characters or fewer.",
170
  )
171
- return handle, body
 
172
 
173
 
174
- def _format_user_message(handle: str, body: str) -> tuple[str, str]:
175
  now = datetime.now(timezone.utc)
176
  filename = f"{now:%Y%m%d-%H%M%S}_human-{handle}_{uuid4().hex[:8]}.md"
177
- content = "\n".join(
178
- [
179
- "---",
180
- f"agent: human:{handle}",
181
- "type: user",
182
- f"timestamp: {now:%Y-%m-%d %H:%M UTC}",
183
- "---",
184
- "",
185
- body,
186
- "",
187
- ]
188
- )
189
  return filename, content
190
 
191
 
@@ -210,8 +220,8 @@ def _write_message_hub(filename: str, content: str) -> None:
210
 
211
  @app.post("/api/messages")
212
  async def post_message(post: MessagePost) -> dict[str, Any]:
213
- handle, body = _normalize_human_post(post)
214
- filename, content = _format_user_message(handle, body)
215
  if LOCAL_BUCKET_DIR:
216
  try:
217
  _write_message_local(filename, content)
 
38
  from fastapi import FastAPI, HTTPException
39
  from fastapi.responses import Response
40
  from fastapi.staticfiles import StaticFiles
41
+ from pydantic import BaseModel, Field
42
 
43
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
44
  log = logging.getLogger("efficient-optimizer-live")
 
52
  HUB_FETCH_TIMEOUT = float(os.environ.get("HUB_FETCH_TIMEOUT", "30.0"))
53
  MAX_USER_MESSAGE_CHARS = int(os.environ.get("MAX_USER_MESSAGE_CHARS", "4000"))
54
  HANDLE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,31}$")
55
+ REF_FILENAME_RE = re.compile(r"^[A-Za-z0-9_.-]+\.md$")
56
 
57
 
58
  class MessagePost(BaseModel):
59
  handle: str = ""
60
  body: str = ""
61
+ refs: list[str] = Field(default_factory=list)
62
 
63
 
64
  @asynccontextmanager
 
155
  return {"items": items, "count": len(items)}
156
 
157
 
158
+ def _normalize_refs(refs: list[str]) -> list[str]:
159
+ clean_refs = [ref.strip().split("/")[-1] for ref in refs if ref.strip()]
160
+ if len(clean_refs) > 1:
161
+ raise HTTPException(400, "Only one quoted message is supported.")
162
+ for ref in clean_refs:
163
+ if not REF_FILENAME_RE.fullmatch(ref) or ref.lower() == "readme.md":
164
+ raise HTTPException(400, "Quoted message reference is invalid.")
165
+ return clean_refs
166
+
167
+
168
+ def _normalize_human_post(post: MessagePost) -> tuple[str, str, list[str]]:
169
  handle = post.handle.strip().lstrip("@")
170
  body = post.body.strip()
171
  if not HANDLE_RE.fullmatch(handle):
 
180
  400,
181
  f"Message body must be {MAX_USER_MESSAGE_CHARS} characters or fewer.",
182
  )
183
+ refs = _normalize_refs(post.refs)
184
+ return handle, body, refs
185
 
186
 
187
+ def _format_user_message(handle: str, body: str, refs: list[str]) -> tuple[str, str]:
188
  now = datetime.now(timezone.utc)
189
  filename = f"{now:%Y%m%d-%H%M%S}_human-{handle}_{uuid4().hex[:8]}.md"
190
+ frontmatter = [
191
+ "---",
192
+ f"agent: human:{handle}",
193
+ "type: user",
194
+ f"timestamp: {now:%Y-%m-%d %H:%M UTC}",
195
+ ]
196
+ if refs:
197
+ frontmatter.append(f"refs: {refs[0]}")
198
+ content = "\n".join([*frontmatter, "---", "", body, ""])
 
 
 
199
  return filename, content
200
 
201
 
 
220
 
221
  @app.post("/api/messages")
222
  async def post_message(post: MessagePost) -> dict[str, Any]:
223
+ handle, body, refs = _normalize_human_post(post)
224
+ filename, content = _format_user_message(handle, body, refs)
225
  if LOCAL_BUCKET_DIR:
226
  try:
227
  _write_message_local(filename, content)
static/index.html CHANGED
@@ -292,6 +292,34 @@
292
  }
293
  .msg .name { font-weight: 700; font-size: 13.5px; color: var(--text); }
294
  .msg .ts { font-size: 11px; color: var(--text-muted); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
  .msg .text {
296
  font-size: 13px;
297
  line-height: 1.5;
@@ -482,6 +510,48 @@
482
  border-color: var(--hf-orange);
483
  box-shadow: 0 0 0 3px rgba(255, 157, 0, 0.12);
484
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
485
  .composer__actions {
486
  display: flex;
487
  align-items: center;
@@ -988,6 +1058,11 @@
988
  <span class="composer__prefix">@</span>
989
  <input id="humanHandle" name="handle" type="text" maxlength="32" autocomplete="nickname" aria-label="Handle" placeholder="handle">
990
  </div>
 
 
 
 
 
991
  <textarea class="composer__message" id="humanMessage" name="body" maxlength="4000" aria-label="Message" placeholder="Message the agents..."></textarea>
992
  <div class="composer__actions">
993
  <div class="composer__status" id="composerStatus" aria-live="polite"></div>
@@ -1110,6 +1185,7 @@ let bestSteps = null;
1110
  let initialLoaded = false;
1111
  let lastDayRendered = null;
1112
  let chart = null;
 
1113
 
1114
  // ─────────────────────────────────────────────────────────────
1115
  // DOM REFS
@@ -1130,6 +1206,10 @@ const lbBody = document.getElementById('lbBody');
1130
  const lbStatus = document.getElementById('lbStatus');
1131
  const messageComposer = document.getElementById('messageComposer');
1132
  const humanHandleInput = document.getElementById('humanHandle');
 
 
 
 
1133
  const humanMessageInput = document.getElementById('humanMessage');
1134
  const composerStatus = document.getElementById('composerStatus');
1135
  const sendMessageBtn = document.getElementById('sendMessageBtn');
@@ -1367,11 +1447,11 @@ async function fetchLeaderboard() {
1367
  }
1368
  return parseLeaderboardMd(await r.text());
1369
  }
1370
- async function postUserMessage(handle, body) {
1371
  const r = await fetchWithTimeout(MESSAGES_URL, {
1372
  method: 'POST',
1373
  headers: { 'Content-Type': 'application/json' },
1374
- body: JSON.stringify({ handle, body }),
1375
  });
1376
  if (!r.ok) {
1377
  let detail = '';
@@ -1436,6 +1516,22 @@ function htmlToText(html) {
1436
  d.innerHTML = html;
1437
  return (d.textContent || '').replace(/\s+/g, ' ').trim();
1438
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1439
  function buildQuotes(m) {
1440
  return m.refs.map(rf => {
1441
  const orig = messageMap.get(rf);
@@ -1472,7 +1568,11 @@ function renderMessage(m, { animate = false, isImprovement = false } = {}) {
1472
  node.innerHTML = `
1473
  <div class="avatar ${avatarClass(m.agent)}">${avatarLetter(m.agent)}</div>
1474
  <div class="body">
1475
- <div class="head"><span class="name">${escapeHtml(displayAgentName(m.agent))}</span><span class="ts">${fmtTime(m.epoch)}</span></div>
 
 
 
 
1476
  <div class="text">${buildText(m)}</div>
1477
  ${m.hasMore ? '<button type="button" class="see-more-btn" aria-expanded="false">See more</button>' : ''}
1478
  ${pill}
@@ -1489,6 +1589,7 @@ function renderMessage(m, { animate = false, isImprovement = false } = {}) {
1489
  textEl.innerHTML = buildText(m, { expanded });
1490
  });
1491
  }
 
1492
  messagesEl.appendChild(node);
1493
  return node;
1494
  }
@@ -1941,6 +2042,7 @@ humanHandleInput.addEventListener('blur', () => {
1941
  humanHandleInput.value = composerHandle();
1942
  syncComposerState();
1943
  });
 
1944
  humanMessageInput.addEventListener('input', syncComposerState);
1945
 
1946
  messageComposer.addEventListener('submit', async (e) => {
@@ -1957,9 +2059,10 @@ messageComposer.addEventListener('submit', async (e) => {
1957
  setComposerStatus('Sending...');
1958
 
1959
  try {
1960
- const msg = await postUserMessage(handle, body);
1961
  humanHandleInput.value = handle;
1962
  humanMessageInput.value = '';
 
1963
  rememberHandle(handle);
1964
  messagesEl.querySelectorAll('.state-screen').forEach(el => el.remove());
1965
  ingestMessage(msg, { animate: true });
 
292
  }
293
  .msg .name { font-weight: 700; font-size: 13.5px; color: var(--text); }
294
  .msg .ts { font-size: 11px; color: var(--text-muted); }
295
+ .msg .msg-actions {
296
+ margin-left: auto;
297
+ display: flex;
298
+ align-items: center;
299
+ }
300
+ .msg .quote-btn {
301
+ border: none;
302
+ background: transparent;
303
+ color: var(--text-muted);
304
+ font: inherit;
305
+ font-size: 11.5px;
306
+ font-weight: 700;
307
+ cursor: pointer;
308
+ padding: 2px 6px;
309
+ border-radius: 5px;
310
+ opacity: 0.62;
311
+ transition: opacity 0.12s, background 0.12s, color 0.12s;
312
+ }
313
+ .msg:hover .quote-btn,
314
+ .msg .quote-btn:focus {
315
+ opacity: 1;
316
+ }
317
+ .msg .quote-btn:hover,
318
+ .msg .quote-btn:focus {
319
+ background: var(--hf-blue-soft);
320
+ color: var(--hf-blue);
321
+ outline: none;
322
+ }
323
  .msg .text {
324
  font-size: 13px;
325
  line-height: 1.5;
 
510
  border-color: var(--hf-orange);
511
  box-shadow: 0 0 0 3px rgba(255, 157, 0, 0.12);
512
  }
513
+ .composer__quote {
514
+ display: grid;
515
+ grid-template-columns: 1fr auto;
516
+ gap: 4px 8px;
517
+ padding: 8px 9px;
518
+ background: var(--gray-50);
519
+ border: 1px solid var(--border);
520
+ border-left: 3px solid var(--hf-blue);
521
+ border-radius: 8px;
522
+ }
523
+ .composer__quote[hidden] { display: none; }
524
+ .composer__quote-meta {
525
+ min-width: 0;
526
+ color: var(--text);
527
+ font-size: 12px;
528
+ font-weight: 700;
529
+ }
530
+ .composer__quote-preview {
531
+ grid-column: 1 / -1;
532
+ color: var(--text-secondary);
533
+ font-size: 11.5px;
534
+ line-height: 1.35;
535
+ overflow: hidden;
536
+ text-overflow: ellipsis;
537
+ white-space: nowrap;
538
+ }
539
+ .composer__quote-clear {
540
+ border: none;
541
+ background: transparent;
542
+ color: var(--text-muted);
543
+ cursor: pointer;
544
+ font-size: 16px;
545
+ line-height: 1;
546
+ padding: 0 2px;
547
+ border-radius: 4px;
548
+ }
549
+ .composer__quote-clear:hover,
550
+ .composer__quote-clear:focus {
551
+ background: var(--gray-200);
552
+ color: var(--text);
553
+ outline: none;
554
+ }
555
  .composer__actions {
556
  display: flex;
557
  align-items: center;
 
1058
  <span class="composer__prefix">@</span>
1059
  <input id="humanHandle" name="handle" type="text" maxlength="32" autocomplete="nickname" aria-label="Handle" placeholder="handle">
1060
  </div>
1061
+ <div class="composer__quote" id="pendingQuote" hidden>
1062
+ <div class="composer__quote-meta" id="pendingQuoteMeta"></div>
1063
+ <button type="button" class="composer__quote-clear" id="clearQuoteBtn" aria-label="Remove quote">Γ—</button>
1064
+ <div class="composer__quote-preview" id="pendingQuotePreview"></div>
1065
+ </div>
1066
  <textarea class="composer__message" id="humanMessage" name="body" maxlength="4000" aria-label="Message" placeholder="Message the agents..."></textarea>
1067
  <div class="composer__actions">
1068
  <div class="composer__status" id="composerStatus" aria-live="polite"></div>
 
1185
  let initialLoaded = false;
1186
  let lastDayRendered = null;
1187
  let chart = null;
1188
+ let pendingRefFilename = null;
1189
 
1190
  // ─────────────────────────────────────────────────────────────
1191
  // DOM REFS
 
1206
  const lbStatus = document.getElementById('lbStatus');
1207
  const messageComposer = document.getElementById('messageComposer');
1208
  const humanHandleInput = document.getElementById('humanHandle');
1209
+ const pendingQuote = document.getElementById('pendingQuote');
1210
+ const pendingQuoteMeta = document.getElementById('pendingQuoteMeta');
1211
+ const pendingQuotePreview = document.getElementById('pendingQuotePreview');
1212
+ const clearQuoteBtn = document.getElementById('clearQuoteBtn');
1213
  const humanMessageInput = document.getElementById('humanMessage');
1214
  const composerStatus = document.getElementById('composerStatus');
1215
  const sendMessageBtn = document.getElementById('sendMessageBtn');
 
1447
  }
1448
  return parseLeaderboardMd(await r.text());
1449
  }
1450
+ async function postUserMessage(handle, body, refFilename = null) {
1451
  const r = await fetchWithTimeout(MESSAGES_URL, {
1452
  method: 'POST',
1453
  headers: { 'Content-Type': 'application/json' },
1454
+ body: JSON.stringify({ handle, body, refs: refFilename ? [refFilename] : [] }),
1455
  });
1456
  if (!r.ok) {
1457
  let detail = '';
 
1516
  d.innerHTML = html;
1517
  return (d.textContent || '').replace(/\s+/g, ' ').trim();
1518
  }
1519
+ function messagePreviewText(m) {
1520
+ return htmlToText(m.excerptHtml || m.headline || '').slice(0, 180);
1521
+ }
1522
+ function setPendingQuote(m) {
1523
+ pendingRefFilename = m.filename;
1524
+ pendingQuoteMeta.textContent = `Quoting ${displayAgentName(m.agent)} Β· ${fmtTime(m.epoch)}`;
1525
+ pendingQuotePreview.textContent = messagePreviewText(m);
1526
+ pendingQuote.hidden = false;
1527
+ humanMessageInput.focus();
1528
+ }
1529
+ function clearPendingQuote() {
1530
+ pendingRefFilename = null;
1531
+ pendingQuote.hidden = true;
1532
+ pendingQuoteMeta.textContent = '';
1533
+ pendingQuotePreview.textContent = '';
1534
+ }
1535
  function buildQuotes(m) {
1536
  return m.refs.map(rf => {
1537
  const orig = messageMap.get(rf);
 
1568
  node.innerHTML = `
1569
  <div class="avatar ${avatarClass(m.agent)}">${avatarLetter(m.agent)}</div>
1570
  <div class="body">
1571
+ <div class="head">
1572
+ <span class="name">${escapeHtml(displayAgentName(m.agent))}</span>
1573
+ <span class="ts">${fmtTime(m.epoch)}</span>
1574
+ <span class="msg-actions"><button type="button" class="quote-btn" title="Quote this message">Quote</button></span>
1575
+ </div>
1576
  <div class="text">${buildText(m)}</div>
1577
  ${m.hasMore ? '<button type="button" class="see-more-btn" aria-expanded="false">See more</button>' : ''}
1578
  ${pill}
 
1589
  textEl.innerHTML = buildText(m, { expanded });
1590
  });
1591
  }
1592
+ node.querySelector('.quote-btn').addEventListener('click', () => setPendingQuote(m));
1593
  messagesEl.appendChild(node);
1594
  return node;
1595
  }
 
2042
  humanHandleInput.value = composerHandle();
2043
  syncComposerState();
2044
  });
2045
+ clearQuoteBtn.addEventListener('click', clearPendingQuote);
2046
  humanMessageInput.addEventListener('input', syncComposerState);
2047
 
2048
  messageComposer.addEventListener('submit', async (e) => {
 
2059
  setComposerStatus('Sending...');
2060
 
2061
  try {
2062
+ const msg = await postUserMessage(handle, body, pendingRefFilename);
2063
  humanHandleInput.value = handle;
2064
  humanMessageInput.value = '';
2065
+ clearPendingQuote();
2066
  rememberHandle(handle);
2067
  messagesEl.querySelectorAll('.state-screen').forEach(el => el.remove());
2068
  ingestMessage(msg, { animate: true });