diff --git a/lyra/web/static/index.html b/lyra/web/static/index.html
index 40ab89e..6fdab29 100644
--- a/lyra/web/static/index.html
+++ b/lyra/web/static/index.html
@@ -473,6 +473,7 @@
up.addEventListener("click", () => rateMessage(div, 1, up, down));
down.addEventListener("click", () => rateMessage(div, -1, up, down));
bar.appendChild(up); bar.appendChild(down);
+ bar.appendChild(makeCopyBtn(() => div.dataset.raw || div.textContent || ""));
div.appendChild(bar);
}
@@ -491,6 +492,62 @@
down.classList.toggle("rated", value === -1);
}
+ // Copy text to the clipboard. Uses the async Clipboard API when available
+ // (HTTPS / localhost), and falls back to a hidden-textarea + execCommand for
+ // iOS over plain-HTTP LAN (where navigator.clipboard is undefined).
+ function copyToClipboard(text) {
+ text = text == null ? "" : String(text);
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ return navigator.clipboard.writeText(text).catch(() => legacyCopy(text));
+ }
+ return legacyCopy(text);
+ }
+ function legacyCopy(text) {
+ return new Promise((resolve, reject) => {
+ const ta = document.createElement("textarea");
+ ta.value = text;
+ ta.contentEditable = "true";
+ ta.readOnly = false;
+ ta.style.position = "fixed";
+ ta.style.top = "0";
+ ta.style.left = "0";
+ ta.style.opacity = "0";
+ document.body.appendChild(ta);
+ const range = document.createRange();
+ range.selectNodeContents(ta);
+ const sel = window.getSelection();
+ sel.removeAllRanges();
+ sel.addRange(range);
+ ta.setSelectionRange(0, text.length); // iOS needs an explicit range
+ let ok = false;
+ try { ok = document.execCommand("copy"); } catch (e) { ok = false; }
+ sel.removeAllRanges();
+ document.body.removeChild(ta);
+ ok ? resolve() : reject(new Error("copy failed"));
+ });
+ }
+ // A small per-message copy button. getText is read at click time.
+ function makeCopyBtn(getText) {
+ const b = document.createElement("button");
+ b.className = "copy-btn";
+ b.type = "button";
+ b.textContent = "⧉";
+ b.title = "Copy message";
+ b.addEventListener("click", (e) => {
+ e.stopPropagation();
+ copyToClipboard(typeof getText === "function" ? getText() : getText)
+ .then(() => {
+ b.textContent = "✓"; b.classList.add("copied");
+ setTimeout(() => { b.textContent = "⧉"; b.classList.remove("copied"); }, 1200);
+ })
+ .catch(() => {
+ b.textContent = "✗";
+ setTimeout(() => { b.textContent = "⧉"; }, 1200);
+ });
+ });
+ return b;
+ }
+
function addMessage(role, text, autoScroll = true) {
const messagesEl = document.getElementById("messages");
@@ -502,6 +559,12 @@
addRateBar(msgDiv);
} else {
msgDiv.textContent = text;
+ if (role === "user") {
+ const bar = document.createElement("div");
+ bar.className = "rate-bar";
+ bar.appendChild(makeCopyBtn(() => text));
+ msgDiv.appendChild(bar);
+ }
}
messagesEl.appendChild(msgDiv);
diff --git a/lyra/web/static/style.css b/lyra/web/static/style.css
index d8b7329..7e014d3 100644
--- a/lyra/web/static/style.css
+++ b/lyra/web/static/style.css
@@ -1182,6 +1182,22 @@ select:hover {
.rate-btn:hover { filter: none; background: var(--accent-soft); }
.rate-btn.rated { filter: none; background: rgba(255,122,0,0.22); opacity: 1; }
+/* Per-message copy button (lives in the rate-bar for assistant, its own bar for user). */
+.copy-btn {
+ background: none; border: none; cursor: pointer; font-size: 0.85rem;
+ padding: 2px 6px; border-radius: 5px; line-height: 1; color: var(--text-fade);
+ -webkit-tap-highlight-color: transparent;
+}
+.copy-btn:hover { background: var(--accent-soft); color: var(--accent); }
+.copy-btn.copied { color: var(--good); }
+/* User bubbles are right-aligned, so right-align their copy bar too. */
+.msg.user .rate-bar { justify-content: flex-end; opacity: 0.4; }
+.msg.user:hover .rate-bar { opacity: 0.85; }
+/* Touch devices have no hover — keep the tools tappable/visible. */
+@media (hover: none) {
+ .rate-bar, .msg.user .rate-bar { opacity: 0.65; }
+}
+
/* Quality floor: honor reduced-motion preference. */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {