|
|
@@ -168,14 +168,27 @@ const streamingStatusMsg = computed(() => {
|
|
|
|
|
|
const renderer = new Renderer();
|
|
|
|
|
|
+const CODE_BLOCK_STORE_MAX = 500;
|
|
|
+let codeBlockCounter = 0;
|
|
|
+const codeBlockStore = new Map();
|
|
|
+
|
|
|
+function storeCodeBlock(text) {
|
|
|
+ const id = String(codeBlockCounter++);
|
|
|
+ codeBlockStore.set(id, text);
|
|
|
+ if (codeBlockStore.size > CODE_BLOCK_STORE_MAX) {
|
|
|
+ codeBlockStore.delete(codeBlockStore.keys().next().value);
|
|
|
+ }
|
|
|
+ return id;
|
|
|
+}
|
|
|
+
|
|
|
renderer.code = ({ text, lang }) => {
|
|
|
const language = lang && hljs.getLanguage(lang) ? lang : "plaintext";
|
|
|
const highlighted = hljs.highlight(text, { language }).value;
|
|
|
- const escapedCode = text.replace(/`/g, "`");
|
|
|
+ const id = storeCodeBlock(text);
|
|
|
return `<div class="code-block-wrapper">
|
|
|
<div class="code-block-header">
|
|
|
<span class="code-lang">${language}</span>
|
|
|
- <button class="code-copy-btn" data-code="${escapedCode}" aria-label="Copiar código">
|
|
|
+ <button class="code-copy-btn" data-code-id="${id}" aria-label="Copiar código">
|
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
|
|
Copiar
|
|
|
</button>
|
|
|
@@ -205,7 +218,7 @@ function renderMarkdown(content) {
|
|
|
if (!content) return "";
|
|
|
const html = marked.parse(content);
|
|
|
return DOMPurify.sanitize(html, {
|
|
|
- ADD_ATTR: ["data-code"],
|
|
|
+ ADD_ATTR: ["data-code-id"],
|
|
|
ADD_TAGS: ["button"],
|
|
|
});
|
|
|
}
|
|
|
@@ -213,7 +226,7 @@ function renderMarkdown(content) {
|
|
|
function handleCodeCopy(e) {
|
|
|
const btn = e.target.closest(".code-copy-btn");
|
|
|
if (!btn) return;
|
|
|
- const code = btn.dataset.code?.replace(/`/g, "`") ?? "";
|
|
|
+ const code = codeBlockStore.get(btn.dataset.codeId) ?? "";
|
|
|
navigator.clipboard.writeText(code).then(() => {
|
|
|
btn.classList.add("copied");
|
|
|
btn.querySelector("svg").style.display = "none";
|
|
|
@@ -246,12 +259,25 @@ function formatarHora(ts) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+const NEAR_BOTTOM_THRESHOLD_PX = 80;
|
|
|
+
|
|
|
+function isNearBottom() {
|
|
|
+ const el = chatEl.value;
|
|
|
+ if (!el) return true;
|
|
|
+ return el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM_THRESHOLD_PX;
|
|
|
+}
|
|
|
+
|
|
|
watch(
|
|
|
- () => [props.messages.length, props.loading],
|
|
|
+ () => [
|
|
|
+ props.messages.length,
|
|
|
+ props.loading,
|
|
|
+ props.messages[props.messages.length - 1]?.content?.length ?? 0
|
|
|
+ ],
|
|
|
async () => {
|
|
|
- await nextTick();
|
|
|
const el = chatEl.value;
|
|
|
- if (!el) return;
|
|
|
+ const shouldStick = !el || isNearBottom();
|
|
|
+ await nextTick();
|
|
|
+ if (!el || !shouldStick) return;
|
|
|
el.scrollTop = el.scrollHeight;
|
|
|
},
|
|
|
{ immediate: true }
|