feat: Timestamps + LaTeX/KaTeX-Rendering im Chat

Timestamps (dd.MM.yyyy HH:mm:ss):
- Neuer _formatTimestamp() + _addTimestamp() Helper in ui.js
- Timestamp bei allen User- und Assistant-Nachrichten (live, history, archiv)
- Erscheint on hover, halbtransparent, rechts oben an der Bubble

LaTeX/Math-Rendering mit KaTeX 0.16.21:
- KaTeX lokal eingebunden (JS + CSS + 21 woff2-Fonts)
- renderMarkdown() schützt LaTeX vor Markdown-Parsing
- $$...$$ → Block-Math (zentriert), $...$ → Inline-Math
- Fehlerhafte Ausdrücke als <code> Fallback
- KaTeX-Display Styling in markdown.css

Weitere Änderungen:
- theme.css: Neue --info Farbe
- Blockquote-Styling: info-Farbe statt halbtransparent
This commit is contained in:
Meleki committed 2026-03-05 18:25:03 +01:00
1 parent dbeba6535a
commit 489b5e0eb0
29 files changed
+119 -11

No files matched your search

+11 -4
View File
@@ -412,6 +412,7 @@
position: relative; position: relative;
} }
.msg-timestamp,
.msg-reply-btn, .msg-reply-btn,
.msg-delete-btn, .msg-delete-btn,
.msg-mark-btn, .msg-mark-btn,
@@ -420,12 +421,17 @@
top: var(--badge-spacing); top: var(--badge-spacing);
opacity: 0; opacity: 0;
transition: opacity var(--transition-duration); transition: opacity var(--transition-duration);
padding: .1em .3em;
font-size: var(--font-size); font-size: var(--font-size);
background: var(--f2-bg);
border: var(--border-width) solid var(--f2-border);
border-radius: var(--border-radius);
z-index: 1; z-index: 1;
white-space: nowrap;
}
.msg-timestamp {
padding: .25em .6em;
border-radius: var(--border-radius);
right: calc(var(--badge-spacing) + 8em + var(--badge-spacing) * 2);
pointer-events: none;
background: var(--f3-bg);
} }
.msg-reply-btn { .msg-reply-btn {
@@ -444,6 +450,7 @@
right: var(--badge-spacing); right: var(--badge-spacing);
} }
.msg:hover .msg-timestamp,
.msg:hover .msg-reply-btn, .msg:hover .msg-reply-btn,
.msg:hover .msg-delete-btn, .msg:hover .msg-delete-btn,
.msg:hover .msg-mark-btn, .msg:hover .msg-mark-btn,
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
Couldn't find the requested file /dist/fonts/KaTeX_Math-Regular.woff2 in katex.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
@font-face{font-family:KaTeX_AMS;font-style:normal;font-weight:400;src:url(fonts/KaTeX_AMS-Regular.woff2) format("woff2"),url(fonts/KaTeX_AMS-Regular.woff) format("woff"),url(fonts/KaTeX_AMS-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Caligraphic;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Caligraphic-Bold.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Bold.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Caligraphic;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Caligraphic-Regular.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Regular.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Fraktur;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Fraktur-Bold.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Bold.woff) format("woff"),url(fonts/KaTeX_Fraktur-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Fraktur;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Fraktur-Regular.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Regular.woff) format("woff"),url(fonts/KaTeX_Fraktur-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Main-Bold.woff2) format("woff2"),url(fonts/KaTeX_Main-Bold.woff) format("woff"),url(fonts/KaTeX_Main-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Main-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Main-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Main-BoldItalic.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Main-Italic.woff2) format("woff2"),url(fonts/KaTeX_Main-Italic.woff) format("woff"),url(fonts/KaTeX_Main-Italic.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Main-Regular.woff2) format("woff2"),url(fonts/KaTeX_Main-Regular.woff) format("woff"),url(fonts/KaTeX_Main-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Math;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Math-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Math-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Math-BoldItalic.ttf) format("truetype")}@font-face{font-family:KaTeX_Math;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Math-Italic.woff2) format("woff2"),url(fonts/KaTeX_Math-Italic.woff) format("woff"),url(fonts/KaTeX_Math-Italic.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:normal;font-weight:700;src:url(fonts/KaTeX_SansSerif-Bold.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Bold.woff) format("woff"),url(fonts/KaTeX_SansSerif-Bold.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:italic;font-weight:400;src:url(fonts/KaTeX_SansSerif-Italic.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Italic.woff) format("woff"),url(fonts/KaTeX_SansSerif-Italic.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:normal;font-weight:400;src:url(fonts/KaTeX_SansSerif-Regular.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Regular.woff) format("woff"),url(fonts/KaTeX_SansSerif-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Script;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Script-Regular.woff2) format("woff2"),url(fonts/KaTeX_Script-Regular.woff) format("woff"),url(fonts/KaTeX_Script-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size1;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size1-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size1-Regular.woff) format("woff"),url(fonts/KaTeX_Size1-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size2;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size2-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size2-Regular.woff) format("woff"),url(fonts/KaTeX_Size2-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size3;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size3-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size3-Regular.woff) format("woff"),url(fonts/KaTeX_Size3-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size4;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size4-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size4-Regular.woff) format("woff"),url(fonts/KaTeX_Size4-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Typewriter;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Typewriter-Regular.woff2) format("woff2"),url(fonts/KaTeX_Typewriter-Regular.woff) format("woff"),url(fonts/KaTeX_Typewriter-Regular.ttf) format("truetype")}.katex{font:normal 1.21em KaTeX_Main,Times New Roman,serif;line-height:1.2;text-indent:0;text-rendering:auto}.katex *{-ms-high-contrast-adjust:none!important;border-color:currentColor}.katex .katex-version:after{content:"0.16.21"}.katex .katex-mathml{clip:rect(1px,1px,1px,1px);border:0;height:1pLine truncated
+29 -2
View File
@@ -84,10 +84,10 @@
} }
.markdown blockquote { .markdown blockquote {
border-left: 3px solid var(--f3-border); border-left: 3px solid var(--info);
margin: 0 0 .5em 0; margin: 0 0 .5em 0;
padding: .5em; padding: .5em;
opacity: var(--semi-transparent); color: var(--info);
} }
.markdown blockquote:last-child { .markdown blockquote:last-child {
@@ -134,6 +134,33 @@
margin-right: .5em; margin-right: .5em;
} }
/* KaTeX Math */
.markdown .katex-display {
margin: .75em .25em;
overflow-x: auto;
overflow-y: hidden;
padding: .25em 0;
text-align: left;
font-size: 1.1em;
}
.markdown .katex-display:first-child {
margin-top: 0;
}
.markdown .katex-display:last-child {
margin-bottom: 0;
}
.markdown .katex {
text-align: left;
}
.markdown .katex-error {
color: var(--dangerous);
}
/* Fluent Emoji */ /* Fluent Emoji */
img.fluent-emoji { img.fluent-emoji {
+1
View File
@@ -5,6 +5,7 @@
--secondary: #4a1; --secondary: #4a1;
--dangerous: #c53; --dangerous: #c53;
--warning: #fa0; --warning: #fa0;
--info: #99c;
--f1-bg: #111; --f1-bg: #111;
--f1-border: #333; --f1-border: #333;
--f2-bg: #222; --f2-bg: #222;
+2
View File
@@ -19,6 +19,7 @@
<link rel="stylesheet" href="css/codemirror.min.css"> <link rel="stylesheet" href="css/codemirror.min.css">
<link rel="stylesheet" href="css/easymde.min.css"> <link rel="stylesheet" href="css/easymde.min.css">
<link rel="stylesheet" href="css/file-explorer.css"> <link rel="stylesheet" href="css/file-explorer.css">
<link rel="stylesheet" href="css/katex.min.css">
<link rel="stylesheet" href="css/markdown.css"> <link rel="stylesheet" href="css/markdown.css">
<link rel="stylesheet" href="css/tooltip.css"> <link rel="stylesheet" href="css/tooltip.css">
<link rel="stylesheet" href="css/mobile.css"> <link rel="stylesheet" href="css/mobile.css">
@@ -435,6 +436,7 @@
</template> </template>
<script src="js/lib/marked.min.js"></script> <script src="js/lib/marked.min.js"></script>
<script src="js/lib/katex.min.js"></script>
<script src="js/lib/codemirror.min.js"></script> <script src="js/lib/codemirror.min.js"></script>
<script src="js/lib/cm-mode-xml.min.js"></script> <script src="js/lib/cm-mode-xml.min.js"></script>
<script src="js/lib/cm-mode-javascript.min.js"></script> <script src="js/lib/cm-mode-javascript.min.js"></script>
+4
View File
@@ -186,6 +186,7 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds) {
div.dataset.timestamp = msg.createdAt || ''; div.dataset.timestamp = msg.createdAt || '';
if (msg.id) div.dataset.messageId = msg.id; if (msg.id) div.dataset.messageId = msg.id;
renderUserContent(div, msg.content); renderUserContent(div, msg.content);
_addTimestamp(div, msg.createdAt);
_addReplyButton(div); _addReplyButton(div);
_addDeleteButton(div, msg.id, msg.deletedOn); _addDeleteButton(div, msg.id, msg.deletedOn);
_addMarkButton(div, msg.id, msg.isMarked); _addMarkButton(div, msg.id, msg.isMarked);
@@ -215,6 +216,7 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds) {
if (msg.id) div.dataset.messageId = msg.id; if (msg.id) div.dataset.messageId = msg.id;
div.innerHTML = renderMarkdown(msg.content); div.innerHTML = renderMarkdown(msg.content);
parseFluentEmojis(div); parseFluentEmojis(div);
_addTimestamp(div, msg.createdAt);
_addReplyButton(div); _addReplyButton(div);
_addDeleteButton(div, msg.id, msg.deletedOn); _addDeleteButton(div, msg.id, msg.deletedOn);
_addMarkButton(div, msg.id, msg.isMarked); _addMarkButton(div, msg.id, msg.isMarked);
@@ -346,10 +348,12 @@ async function loadLatestArchive() {
div.className = 'msg user markdown archived'; div.className = 'msg user markdown archived';
if (msg.id) div.dataset.messageId = msg.id; if (msg.id) div.dataset.messageId = msg.id;
renderUserContent(div, msg.content); renderUserContent(div, msg.content);
_addTimestamp(div, msg.createdAt);
} else { } else {
div.className = 'msg assistant markdown archived'; div.className = 'msg assistant markdown archived';
div.innerHTML = renderMarkdown(msg.content); div.innerHTML = renderMarkdown(msg.content);
parseFluentEmojis(div); parseFluentEmojis(div);
_addTimestamp(div, msg.createdAt);
} }
if (msg.role === 'user' && msg.attachmentCount > 0) { if (msg.role === 'user' && msg.attachmentCount > 0) {
if (/\[Bild: [^\]]+\]/.test(msg.content)) archiveImageIds.push(msg.id); if (/\[Bild: [^\]]+\]/.test(msg.content)) archiveImageIds.push(msg.id);
+1
View File
@@ -0,0 +1 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.katex=t():e.katex=t()}("undefined"!=typeof self?self:this,(function(){return function(){"use strict";var e={d:function(t,r){for(var n in r)e.o(r,n)&&!e.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:r[n]})},o:function(e,t){return Object.prototype.hasOwnProperty.call(e,t)}},t={};e.d(t,{default:function(){return Wn}});class r{constructor(e,t){this.name=void 0,this.position=void 0,this.length=void 0,this.rawMessage=void 0;let n,o,s="KaTeX parse error: "+e;const i=t&&t.loc;if(i&&i.start<=i.end){const e=i.lexer.input;n=i.start,o=i.end,n===e.length?s+=" at end of input: ":s+=" at position "+(n+1)+": ";const t=e.slice(n,o).replace(/[^]/g,"$&\u0332");let r,a;r=n>15?"\u2026"+e.slice(n-15,n):e.slice(0,n),a=o+15<e.length?e.slice(o,o+15)+"\u2026":e.slice(o),s+=r+t+a}const a=new Error(s);return a.name="ParseError",a.__proto__=r.prototype,a.position=n,null!=n&&null!=o&&(a.length=o-n),a.rawMessage=e,a}}r.prototype.__proto__=Error.prototype;var n=r;const o=/([A-Z])/g,s={"&":"&amp;",">":"&gt;","<":"&lt;",'"':"&quot;","'":"&#x27;"},i=/[&><"']/g;const a=function(e){return"ordgroup"===e.type||"color"===e.type?1===e.body.length?a(e.body[0]):e:"font"===e.type?a(e.body):e};var l={contains:function(e,t){return-1!==e.indexOf(t)},deflt:function(e,t){return void 0===e?t:e},escape:function(e){return String(e).replace(i,(e=>s[e]))},hyphenate:function(e){return e.replace(o,"-$1").toLowerCase()},getBaseElem:a,isCharacterBox:function(e){const t=a(e);return"mathord"===t.type||"textord"===t.type||"atom"===t.type},protocolFromUrl:function(e){const t=/^[\x00-\x20]*([^\\/#?]*?)(:|&#0*58|&#x0*3a|&colon)/i.exec(e);return t?":"!==t[2]?null:/^[a-zA-Z][a-zA-Z0-9+\-.]*$/.test(t[1])?t[1].toLowerCase():null:"_relative"}};const h={displayMode:{type:"boolean",description:"Render math in display mode, which puts the math in display style (so \\int and \\sum are large, for example), and centers the math on the page on its own line.",cli:"-d, --display-mode"},output:{type:{enum:["htmlAndMathml","html","mathml"]},description:"Determines the markup language of the output.",cli:"-F, --format <type>"},leqno:{type:"boolean",description:"Render display math in leqno style (left-justified tags)."},fleqn:{type:"boolean",description:"Render display math flush left."},throwOnError:{type:"boolean",default:!0,cli:"-t, --no-throw-on-error",cliDescription:"Render errors (in the color given by --error-color) instead of throwing a ParseError exception when encountering an error."},errorColor:{type:"string",default:"#cc0000",cli:"-c, --error-color <color>",cliDescription:"A color string given in the format 'rgb' or 'rrggbb' (no #). This option determines the color of errors rendered by the -t option.",cliProcessor:e=>"#"+e},macros:{type:"object",cli:"-m, --macro <def>",cliDescription:"Define custom macro of the form '\\foo:expansion' (use multiple -m arguments for multiple macros).",cliDefault:[],cliProcessor:(e,t)=>(t.push(e),t)},minRuleThickness:{type:"number",description:"Specifies a minimum thickness, in ems, for fraction lines, `\\sqrt` top lines, `{array}` vertical lines, `\\hline`, `\\hdashline`, `\\underline`, `\\overline`, and the borders of `\\fbox`, `\\boxed`, and `\\fcolorbox`.",processor:e=>Math.max(0,e),cli:"--min-rule-thickness <size>",cliProcessor:parseFloat},colorIsTextColor:{type:"boolean",description:"Makes \\color behave like LaTeX's 2-argument \\textcolor, instead of LaTeX's one-argument \\color mode change.",cli:"-b, --color-is-text-color"},strict:{type:[{enum:["warn","ignore","error"]},"boolean","function"],description:"Turn on strict / LaTeX faithfulness mode, which throws an error if the input uses features that are not supported by LaTeX.",cli:"-S, --strict",cliDefault:!1},trust:{type:["boolean","function"],description:"Trust the input, enabling all HTML features such as \\url.",cli:"-T, --trust"},maxSize:{type:"number",default:1/0,description:"If non-zero, all user-specified sizes, e.g. in \\rule{500em}{500em}, will be capped to maxSize ems. Otherwise, elements and spaces can be arbitrarily large",processor:e=>Math.max(0,e),cli:"-s, --max-size <n>",cliProcessor:parseInt},maxExpand:{type:"number",default:1e3,description:"Limit the number of macro expansions to the specified number, to prevent e.g. infinite macro loops. If set to Infinity, the macro expander will try to fully expand as in LaTeX.",processor:e=>Math.max(0,e),cli:"-e, --max-expand <n>",cliProcessor:e=>"Infinity"===e?1/0:parseInt(e)},globalGroup:{type:"boolean",cli:!1}};function c(e){if(e.default)return e.default;const t=e.type,r=Array.isArray(t)?t[0]:t;if("string"!=typeof r)return r.enum[0];switch(r){case"boolean":return!1;case"string":return"";case"number":return 0;case"object":return{}}}class m{constructor(e){this.displayMode=void 0,this.output=void 0,this.leqno=void 0,this.fleqn=void 0,this.throwOnError=void 0Line truncated
+69 -5
View File
@@ -54,14 +54,33 @@ marked.setOptions({ breaks: true, gfm: true });
function renderMarkdown(text) { function renderMarkdown(text) {
if (!text) return ''; if (!text) return '';
var html = marked.parse(text.trim()); var src = text.trim();
// Code-Blöcke (<pre>...<code>...</code>...</pre> und inline <code>...</code>) schützen
// 1) LaTeX-Ausdrücke VOR dem Markdown-Parsing schützen
// Reihenfolge: zuerst Block ($$...$$), dann Inline ($...$)
var mathBlocks = [];
// Block-Math: $$...$$ (auch mehrzeilig)
src = src.replace(/\$\$([\s\S]+?)\$\$/g, function(m, latex) {
mathBlocks.push({ latex: latex, displayMode: true });
return '%%MATH' + (mathBlocks.length - 1) + '%%';
});
// Inline-Math: $...$ (nicht-gierig, kein $ am Anfang/Ende)
src = src.replace(/(?<!\$)\$(?!\$)(.+?)(?<!\$)\$(?!\$)/g, function(m, latex) {
mathBlocks.push({ latex: latex, displayMode: false });
return '%%MATH' + (mathBlocks.length - 1) + '%%';
});
// 2) Markdown parsen
var html = marked.parse(src);
// 3) Code-Blöcke schützen (damit Emojis/Krea dort nicht greifen)
var codeBlocks = []; var codeBlocks = [];
html = html.replace(/<pre[\s>][\s\S]*?<\/pre>|<code[\s>][\s\S]*?<\/code>/gi, function(m) { html = html.replace(/<pre[\s>][\s\S]*?<\/pre>|<code[\s>][\s\S]*?<\/code>/gi, function(m) {
codeBlocks.push(m); codeBlocks.push(m);
return '%%CODE' + (codeBlocks.length - 1) + '%%'; return '%%CODE' + (codeBlocks.length - 1) + '%%';
}); });
// [Krea: dateiname.png] → Inline-Thumbnail mit Klick für Vollbild
// 4) [Krea: dateiname.png] → Inline-Thumbnail mit Klick für Vollbild
html = html.replace(/\[Krea:\s*([^\]]+)\]/g, function(match, filename) { html = html.replace(/\[Krea:\s*([^\]]+)\]/g, function(match, filename) {
var fn = filename.trim(); var fn = filename.trim();
var safeFn = esc(fn); var safeFn = esc(fn);
@@ -71,12 +90,30 @@ function renderMarkdown(text) {
+ '<img class="krea-thumb" src="' + thumbUrl + '" alt="' + safeFn + '" loading="lazy">' + '<img class="krea-thumb" src="' + thumbUrl + '" alt="' + safeFn + '" loading="lazy">'
+ '</span>'; + '</span>';
}); });
// Unicode-Emojis durch Fluent Emoji 3D ersetzen (außerhalb von Code-Blöcken)
// 5) Unicode-Emojis durch Fluent Emoji 3D ersetzen (außerhalb von Code-Blöcken)
html = replaceEmojisWithFluent(html); html = replaceEmojisWithFluent(html);
// Code-Blöcke wiederherstellen
// 6) Code-Blöcke wiederherstellen
html = html.replace(/%%CODE(\d+)%%/g, function(m, idx) { html = html.replace(/%%CODE(\d+)%%/g, function(m, idx) {
return codeBlocks[parseInt(idx)]; return codeBlocks[parseInt(idx)];
}); });
// 7) LaTeX-Platzhalter durch KaTeX-gerenderten HTML ersetzen
html = html.replace(/%%MATH(\d+)%%/g, function(m, idx) {
var block = mathBlocks[parseInt(idx)];
try {
return katex.renderToString(block.latex.trim(), {
displayMode: block.displayMode,
throwOnError: false,
output: 'html'
});
} catch (e) {
// Fallback: Originaltext anzeigen
return '<code class="katex-error">' + esc(block.latex) + '</code>';
}
});
return html; return html;
} }
@@ -307,6 +344,31 @@ function _showShareMenu(anchorBtn) {
}); });
} }
// ── Timestamp ──
function _formatTimestamp(isoString) {
if (!isoString) return '';
var d = new Date(isoString);
if (isNaN(d.getTime())) return '';
var dd = String(d.getDate()).padStart(2, '0');
var mm = String(d.getMonth() + 1).padStart(2, '0');
var yyyy = d.getFullYear();
var hh = String(d.getHours()).padStart(2, '0');
var min = String(d.getMinutes()).padStart(2, '0');
var ss = String(d.getSeconds()).padStart(2, '0');
return dd + '.' + mm + '.' + yyyy + ' ' + hh + ':' + min + ':' + ss;
}
function _addTimestamp(div, timestamp) {
if (!timestamp) return;
var formatted = _formatTimestamp(timestamp);
if (!formatted) return;
var span = document.createElement('span');
span.className = 'msg-timestamp';
span.textContent = formatted;
div.prepend(span);
}
// ── Reply Button ── // ── Reply Button ──
function _addReplyButton(div) { function _addReplyButton(div) {
@@ -440,6 +502,7 @@ function addUserBubble(text, streamed = false, timestamp = '', messageId = null,
if (messageId) div.dataset.messageId = messageId; if (messageId) div.dataset.messageId = messageId;
if (streamed) div.classList.add('streamed'); if (streamed) div.classList.add('streamed');
renderUserContent(div, text, inlineImages); renderUserContent(div, text, inlineImages);
_addTimestamp(div, timestamp);
_addReplyButton(div); _addReplyButton(div);
_addDeleteButton(div, messageId, deletedOn); _addDeleteButton(div, messageId, deletedOn);
_addMarkButton(div, messageId, isMarked); _addMarkButton(div, messageId, isMarked);
@@ -458,6 +521,7 @@ function addAssistantBubble(text, timestamp = '', messageId = null, isMarked = f
div.innerHTML = renderMarkdown(text); div.innerHTML = renderMarkdown(text);
parseFluentEmojis(div); parseFluentEmojis(div);
wrapKreaGalleries(div); wrapKreaGalleries(div);
_addTimestamp(div, timestamp);
_addReplyButton(div); _addReplyButton(div);
_addDeleteButton(div, messageId, deletedOn); _addDeleteButton(div, messageId, deletedOn);
_addMarkButton(div, messageId, isMarked); _addMarkButton(div, messageId, isMarked);