mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 19:20:16 +00:00
Stage 381: PR #2485
This commit is contained in:
@@ -44,6 +44,9 @@ const LOCALES = {
|
||||
copy: 'Copy',
|
||||
copied: 'Copied!',
|
||||
copy_failed: 'Copy failed',
|
||||
selected_text_reply: 'Reply with selection',
|
||||
selected_text_reply_title: 'Append selected chat text as quoted context',
|
||||
selected_text_reply_appended: 'Selected text added to composer',
|
||||
|
||||
diff_loading: 'Loading diff',
|
||||
diff_error: 'Could not load patch file',
|
||||
@@ -1252,6 +1255,9 @@ const LOCALES = {
|
||||
copy: 'Copia',
|
||||
copied: 'Copiato!',
|
||||
copy_failed: 'Copia fallita',
|
||||
selected_text_reply: 'Rispondi con selezione',
|
||||
selected_text_reply_title: 'Aggiungi il testo della chat selezionato come contesto citato',
|
||||
selected_text_reply_appended: 'Testo selezionato aggiunto al compositore',
|
||||
|
||||
diff_loading: 'Caricamento diff',
|
||||
diff_error: 'Impossibile caricare il file patch',
|
||||
@@ -2452,6 +2458,9 @@ const LOCALES = {
|
||||
copy: 'コピー',
|
||||
copied: 'コピーしました!',
|
||||
copy_failed: 'コピー失敗',
|
||||
selected_text_reply: '選択範囲で返信',
|
||||
selected_text_reply_title: '選択したチャットテキストを引用コンテキストとして追加',
|
||||
selected_text_reply_appended: '選択したテキストを入力欄に追加しました',
|
||||
|
||||
diff_loading: '差分を読み込み中',
|
||||
diff_error: 'パッチファイルを読み込めませんでした',
|
||||
@@ -3654,6 +3663,9 @@ const LOCALES = {
|
||||
copy: 'Копировать',
|
||||
copied: 'Скопировано!',
|
||||
copy_failed: '\u041e\u0448\u0438\u0431\u043a\u0430 \u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f',
|
||||
selected_text_reply: 'Ответить с выделенным',
|
||||
selected_text_reply_title: 'Добавить выделенный текст чата как цитируемый контекст',
|
||||
selected_text_reply_appended: 'Выделенный текст добавлен в поле ввода',
|
||||
|
||||
diff_loading: 'Загрузка diff',
|
||||
diff_error: 'Не удалось загрузить файл патча',
|
||||
@@ -4788,6 +4800,9 @@ const LOCALES = {
|
||||
copy: 'Copiar',
|
||||
copied: '¡Copiado!',
|
||||
copy_failed: 'Error al copiar',
|
||||
selected_text_reply: 'Responder con selección',
|
||||
selected_text_reply_title: 'Añadir el texto del chat seleccionado como contexto citado',
|
||||
selected_text_reply_appended: 'Texto seleccionado añadido al compositor',
|
||||
|
||||
diff_loading: 'Cargando diff',
|
||||
diff_error: 'No se pudo cargar el archivo de parche',
|
||||
@@ -5925,6 +5940,9 @@ const LOCALES = {
|
||||
copy: 'Kopieren',
|
||||
copied: 'Kopiert!',
|
||||
copy_failed: 'Kopieren fehlgeschlagen',
|
||||
selected_text_reply: 'Mit Auswahl antworten',
|
||||
selected_text_reply_title: 'Ausgewählten Chattext als Zitatkontext anhängen',
|
||||
selected_text_reply_appended: 'Auswahl zum Composer hinzugefügt',
|
||||
|
||||
diff_loading: 'Lade Diff',
|
||||
diff_error: 'Patch-Datei konnte nicht geladen werden',
|
||||
@@ -7066,6 +7084,9 @@ const LOCALES = {
|
||||
copy: '复制',
|
||||
copied: '已复制',
|
||||
copy_failed: '复制失败',
|
||||
selected_text_reply: '用所选内容回复',
|
||||
selected_text_reply_title: '将所选聊天文本作为引用上下文追加',
|
||||
selected_text_reply_appended: '已将所选文本添加到输入框',
|
||||
|
||||
diff_loading: '加载 diff',
|
||||
diff_error: '无法加载 patch 文件',
|
||||
@@ -8194,6 +8215,9 @@ const LOCALES = {
|
||||
copy: '\u8907\u88fd',
|
||||
copied: '\u5df2\u8907\u88fd',
|
||||
copy_failed: '\u8907\u88fd\u5931\u6557',
|
||||
selected_text_reply: '用所選內容回覆',
|
||||
selected_text_reply_title: '將所選聊天文字作為引用上下文附加',
|
||||
selected_text_reply_appended: '已將所選文字加入輸入框',
|
||||
|
||||
diff_loading: '載入 diff',
|
||||
diff_error: '無法載入 patch 檔案',
|
||||
@@ -9422,6 +9446,9 @@ const LOCALES = {
|
||||
copy: 'Copiar',
|
||||
copied: 'Copiado!',
|
||||
copy_failed: 'Falha ao copiar',
|
||||
selected_text_reply: 'Responder com seleção',
|
||||
selected_text_reply_title: 'Anexar o texto selecionado do chat como contexto citado',
|
||||
selected_text_reply_appended: 'Texto selecionado adicionado ao compositor',
|
||||
you: 'Você',
|
||||
thinking: 'Pensando',
|
||||
expand_all: 'Expandir tudo',
|
||||
@@ -10448,6 +10475,9 @@ const LOCALES = {
|
||||
copy: '복사',
|
||||
copied: '복사됨!',
|
||||
copy_failed: '복사 실패',
|
||||
selected_text_reply: '선택 항목으로 답장',
|
||||
selected_text_reply_title: '선택한 채팅 텍스트를 인용 컨텍스트로 추가',
|
||||
selected_text_reply_appended: '선택한 텍스트가 입력창에 추가되었습니다',
|
||||
|
||||
diff_loading: 'diff 불러오는 중',
|
||||
diff_error: '패치 파일을 로드할 수 없습니다',
|
||||
@@ -11646,6 +11676,9 @@ const LOCALES = {
|
||||
copy: 'Copie',
|
||||
copied: 'Copié!',
|
||||
copy_failed: 'Échec de la copie',
|
||||
selected_text_reply: 'Répondre avec la sélection',
|
||||
selected_text_reply_title: 'Ajouter le texte sélectionné du chat comme contexte cité',
|
||||
selected_text_reply_appended: 'Texte sélectionné ajouté au compositeur',
|
||||
diff_loading: 'Chargement des différences',
|
||||
diff_error: 'Impossible de charger le fichier de correctif',
|
||||
diff_too_large: 'Fichier de correctif trop volumineux pour être affiché en ligne',
|
||||
|
||||
@@ -52,6 +52,137 @@ const _msgEl=document.getElementById('msg');
|
||||
if(_msgEl) _msgEl.addEventListener('focus', ()=>{ if('speechSynthesis' in window && speechSynthesis.speaking) speechSynthesis.pause(); });
|
||||
if(_msgEl) _msgEl.addEventListener('blur', ()=>{ if('speechSynthesis' in window && speechSynthesis.paused) speechSynthesis.resume(); });
|
||||
|
||||
let _selectedTextReplyBtn=null;
|
||||
let _selectedTextReplyText='';
|
||||
let _selectedTextReplyRaf=0;
|
||||
|
||||
function _selectedTextReplyT(key, fallback){
|
||||
try{
|
||||
const val=(typeof t==='function')?t(key):'';
|
||||
return val&&val!==key?val:fallback;
|
||||
}catch(_err){
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function _selectedTextReplyRoot(){
|
||||
if(typeof $==='function') return $('messages')||$('msgInner');
|
||||
return document.getElementById('messages')||document.getElementById('msgInner');
|
||||
}
|
||||
|
||||
function _selectedTextReplyNodeInChat(node, root){
|
||||
if(!node||!root)return false;
|
||||
const el=node.nodeType===Node.ELEMENT_NODE?node:node.parentElement;
|
||||
return !!(el&&root.contains(el));
|
||||
}
|
||||
|
||||
function _selectedTextReplySelection(){
|
||||
if(!window.getSelection)return null;
|
||||
const selection=window.getSelection();
|
||||
if(!selection||selection.isCollapsed||!selection.rangeCount)return null;
|
||||
const root=_selectedTextReplyRoot();
|
||||
if(!root)return null;
|
||||
const range=selection.getRangeAt(0);
|
||||
if(!_selectedTextReplyNodeInChat(range.startContainer, root)||!_selectedTextReplyNodeInChat(range.endContainer, root))return null;
|
||||
const text=selection.toString().replace(/\u00a0/g,' ').trim();
|
||||
if(!text)return null;
|
||||
const rect=range.getBoundingClientRect();
|
||||
if(!rect||(!rect.width&&!rect.height))return null;
|
||||
return {text, rect};
|
||||
}
|
||||
|
||||
function _formatSelectedTextReplyQuote(text){
|
||||
const normalized=String(text||'').replace(/\r\n?/g,'\n').replace(/\n{3,}/g,'\n\n').trim();
|
||||
if(!normalized)return '';
|
||||
return normalized.split('\n').map(line=>`> ${line}`).join('\n');
|
||||
}
|
||||
|
||||
function _appendSelectedTextReplyToComposer(text){
|
||||
const composer=(typeof $==='function'&&$('msg'))||document.getElementById('msg');
|
||||
if(!composer)return false;
|
||||
const quote=_formatSelectedTextReplyQuote(text);
|
||||
if(!quote)return false;
|
||||
const current=String(composer.value||'');
|
||||
composer.value=current.trim()?`${current.replace(/\s+$/,'')}\n\n${quote}\n\n`:`${quote}\n\n`;
|
||||
composer.focus();
|
||||
try{ composer.setSelectionRange(composer.value.length, composer.value.length); }catch(_err){}
|
||||
composer.dispatchEvent(new Event('input', {bubbles:true}));
|
||||
if(typeof autoResize==='function') autoResize();
|
||||
if(typeof showToast==='function') showToast(_selectedTextReplyT('selected_text_reply_appended', 'Selected text added to composer'), 1600);
|
||||
return true;
|
||||
}
|
||||
|
||||
function _selectedTextReplyButton(){
|
||||
if(_selectedTextReplyBtn)return _selectedTextReplyBtn;
|
||||
const btn=document.createElement('button');
|
||||
btn.type='button';
|
||||
btn.id='selectedTextReplyBtn';
|
||||
btn.className='selected-text-reply-btn';
|
||||
btn.setAttribute('data-i18n', 'selected_text_reply');
|
||||
btn.setAttribute('data-i18n-title', 'selected_text_reply_title');
|
||||
btn.setAttribute('data-i18n-aria-label', 'selected_text_reply_title');
|
||||
btn.textContent=_selectedTextReplyT('selected_text_reply', 'Reply with selection');
|
||||
btn.title=_selectedTextReplyT('selected_text_reply_title', 'Append selected chat text as quoted context');
|
||||
btn.setAttribute('aria-label', btn.title);
|
||||
btn.addEventListener('mousedown', e=>e.preventDefault());
|
||||
btn.addEventListener('click', e=>{
|
||||
e.preventDefault();
|
||||
if(_appendSelectedTextReplyToComposer(_selectedTextReplyText)){
|
||||
_hideSelectedTextReplyButton();
|
||||
const selection=window.getSelection&&window.getSelection();
|
||||
if(selection&&selection.removeAllRanges)selection.removeAllRanges();
|
||||
}
|
||||
});
|
||||
document.body.appendChild(btn);
|
||||
if(typeof applyLocaleToDOM==='function') applyLocaleToDOM();
|
||||
_selectedTextReplyBtn=btn;
|
||||
return btn;
|
||||
}
|
||||
|
||||
function _hideSelectedTextReplyButton(){
|
||||
_selectedTextReplyText='';
|
||||
if(_selectedTextReplyBtn)_selectedTextReplyBtn.classList.remove('visible');
|
||||
}
|
||||
|
||||
function _positionSelectedTextReplyButton(info){
|
||||
const btn=_selectedTextReplyButton();
|
||||
_selectedTextReplyText=info.text;
|
||||
btn.classList.add('visible');
|
||||
const gap=8;
|
||||
const btnRect=btn.getBoundingClientRect();
|
||||
const width=btnRect.width||150;
|
||||
const height=btnRect.height||32;
|
||||
const left=Math.min(Math.max(gap, info.rect.left+(info.rect.width/2)-(width/2)), Math.max(gap, window.innerWidth-width-gap));
|
||||
const top=Math.max(gap, info.rect.top-height-gap);
|
||||
btn.style.left=`${left}px`;
|
||||
btn.style.top=`${top}px`;
|
||||
}
|
||||
|
||||
function _updateSelectedTextReplyButton(){
|
||||
if(_selectedTextReplyRaf)return;
|
||||
_selectedTextReplyRaf=window.requestAnimationFrame(()=>{
|
||||
_selectedTextReplyRaf=0;
|
||||
const info=_selectedTextReplySelection();
|
||||
if(!info){
|
||||
_hideSelectedTextReplyButton();
|
||||
return;
|
||||
}
|
||||
_positionSelectedTextReplyButton(info);
|
||||
});
|
||||
}
|
||||
|
||||
if(typeof document!=='undefined'){
|
||||
document.addEventListener('selectionchange', _updateSelectedTextReplyButton);
|
||||
document.addEventListener('mouseup', e=>{
|
||||
if(e.target&&e.target.closest&&e.target.closest('.selected-text-reply-btn'))return;
|
||||
_updateSelectedTextReplyButton();
|
||||
});
|
||||
document.addEventListener('keyup', e=>{
|
||||
if(e.key&&/Arrow|Shift|Control|Meta|Alt/.test(e.key))_updateSelectedTextReplyButton();
|
||||
});
|
||||
window.addEventListener('resize', _hideSelectedTextReplyButton);
|
||||
}
|
||||
|
||||
// Guard against concurrent send() calls. Without this, two rapid sends
|
||||
// (e.g. queue drain + user click) can both pass the S.busy check because
|
||||
// setBusy(true) is only called after the first await inside send().
|
||||
|
||||
@@ -1847,6 +1847,10 @@
|
||||
.msg-row:hover .msg-actions{opacity:1;}
|
||||
.msg-action-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:13px;padding:2px 5px;border-radius:5px;transition:color .12s,background .12s;line-height:1;}
|
||||
.msg-action-btn:hover{color:var(--accent-text);background:var(--accent-bg);}
|
||||
.selected-text-reply-btn{position:fixed;z-index:1200;display:inline-flex;align-items:center;gap:6px;padding:7px 11px;border:2px solid var(--accent);border-radius:999px;background:var(--bg);color:var(--text);box-shadow:0 8px 24px rgba(0,0,0,.26),0 0 0 1px var(--surface);font-size:12px;font-weight:700;line-height:1;cursor:pointer;opacity:0;pointer-events:none;transform:translateY(4px);transition:opacity .12s ease,transform .12s ease,background .12s ease,border-color .12s ease;}
|
||||
.selected-text-reply-btn.visible{opacity:1;pointer-events:auto;transform:translateY(0);}
|
||||
.selected-text-reply-btn:hover{background:var(--accent-bg);border-color:var(--accent-hover);}
|
||||
.selected-text-reply-btn:focus-visible{background:var(--accent-bg);border-color:var(--accent-hover);outline:2px solid var(--focus-ring);outline-offset:2px;}
|
||||
/* TTS speaker button: hidden by default, shown when TTS is enabled.
|
||||
* Use body-class selector instead of JS inline-style so the rule survives
|
||||
* subsequent renderMd() passes and is not subject to inline-style cascade
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def read(rel: str) -> str:
|
||||
return (REPO / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _locale_blocks(src: str) -> dict[str, str]:
|
||||
matches = list(
|
||||
re.finditer(
|
||||
r"\n (?:(['\"])([A-Za-z][A-Za-z0-9-]*)\1|([A-Za-z][A-Za-z0-9-]*)): \{",
|
||||
src,
|
||||
)
|
||||
)
|
||||
blocks: dict[str, str] = {}
|
||||
for idx, match in enumerate(matches):
|
||||
start = match.end()
|
||||
end = matches[idx + 1].start() if idx + 1 < len(matches) else src.rfind("\n};")
|
||||
blocks[match.group(2) or match.group(3)] = src[start:end]
|
||||
return blocks
|
||||
|
||||
|
||||
def test_selected_text_reply_button_is_selection_scoped_and_frontend_only():
|
||||
js = read("static/messages.js")
|
||||
|
||||
assert "window.getSelection" in js
|
||||
assert "selection.isCollapsed" in js
|
||||
assert "range.getBoundingClientRect" in js
|
||||
assert "_selectedTextReplyRoot" in js
|
||||
assert "$('messages')||$('msgInner')" in js
|
||||
assert "root.contains(el)" in js
|
||||
assert "document.addEventListener('selectionchange', _updateSelectedTextReplyButton)" in js
|
||||
|
||||
assert "id='selectedTextReplyBtn'" in js
|
||||
assert "selected-text-reply-btn" in js
|
||||
assert "data-i18n', 'selected_text_reply'" in js
|
||||
assert "data-i18n-title', 'selected_text_reply_title'" in js
|
||||
assert "data-i18n-aria-label', 'selected_text_reply_title'" in js
|
||||
|
||||
# MVP contract: selected text reply is entirely static/frontend; do not add
|
||||
# backend endpoints or change send payload routing.
|
||||
assert "/api/selected" not in js
|
||||
assert "selected_text" not in js.replace("selected_text_reply", "")
|
||||
|
||||
|
||||
def test_selected_text_reply_appends_blockquote_and_preserves_draft_flow():
|
||||
js = read("static/messages.js")
|
||||
|
||||
assert "function _formatSelectedTextReplyQuote" in js
|
||||
assert "replace(/\\r\\n?/g,'\\n')" in js
|
||||
assert "replace(/\\n{3,}/g,'\\n\\n')" in js
|
||||
assert "map(line=>`> ${line}`).join('\\n')" in js
|
||||
|
||||
assert "function _appendSelectedTextReplyToComposer" in js
|
||||
assert "$('msg')" in js
|
||||
assert "current.trim()?" in js
|
||||
assert "${quote}\\n\\n" in js
|
||||
assert "composer.dispatchEvent(new Event('input', {bubbles:true}))" in js
|
||||
assert "if(typeof autoResize==='function') autoResize()" in js
|
||||
|
||||
|
||||
def test_selected_text_reply_styles_and_i18n_exist_for_all_locales():
|
||||
css = read("static/style.css")
|
||||
i18n = read("static/i18n.js")
|
||||
|
||||
assert ".selected-text-reply-btn" in css
|
||||
assert ".selected-text-reply-btn.visible" in css
|
||||
assert "position:fixed" in css
|
||||
assert "pointer-events:none" in css
|
||||
assert "pointer-events:auto" in css
|
||||
assert "border:2px solid var(--accent)" in css
|
||||
assert "background:var(--bg)" in css
|
||||
assert "color:var(--text)" in css
|
||||
assert "outline:2px solid var(--focus-ring)" in css
|
||||
|
||||
blocks = _locale_blocks(i18n)
|
||||
assert blocks, "No locale blocks found"
|
||||
assert "zh-Hant" in blocks, "Locale parser must include quoted script locales"
|
||||
required = {
|
||||
"selected_text_reply",
|
||||
"selected_text_reply_title",
|
||||
"selected_text_reply_appended",
|
||||
}
|
||||
key_pattern = re.compile(r"^\s{4}([a-zA-Z0-9_]+):", re.MULTILINE)
|
||||
for locale, block in blocks.items():
|
||||
keys = set(key_pattern.findall(block))
|
||||
missing = sorted(required - keys)
|
||||
assert not missing, f"{locale} missing selected-text reply keys: {missing}"
|
||||
Reference in New Issue
Block a user