mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 19:20:16 +00:00
Merge pull request #2303 into stage-359
Add assistant question jump buttons (franksong2702, fixes #2246) # Conflicts: # CHANGELOG.md
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 152 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 131 KiB |
@@ -150,6 +150,8 @@ const LOCALES = {
|
||||
session_jump_start_label: 'Jump to beginning of session',
|
||||
session_jump_end: 'End',
|
||||
session_jump_end_label: 'Jump to end of session',
|
||||
jump_to_question: 'to question',
|
||||
jump_to_question_label: 'Jump to the question for this response',
|
||||
queued_label: 'Sends after response',
|
||||
queued_count: (n) => n === 1 ? '1 queued' : `${n} queued`,
|
||||
queued_cancel: 'Cancel queued message',
|
||||
@@ -1337,6 +1339,8 @@ const LOCALES = {
|
||||
session_jump_start_label: "Vai all'inizio della sessione",
|
||||
session_jump_end: 'Fine',
|
||||
session_jump_end_label: 'Vai alla fine della sessione',
|
||||
jump_to_question: 'alla domanda',
|
||||
jump_to_question_label: 'Vai alla domanda di questa risposta',
|
||||
queued_label: 'Inviato dopo la risposta',
|
||||
queued_count: (n) => n === 1 ? '1 in coda' : `${n} in coda`,
|
||||
queued_cancel: 'Annulla messaggio in coda',
|
||||
@@ -2516,6 +2520,8 @@ const LOCALES = {
|
||||
session_jump_start_label: 'セッションの先頭へ移動',
|
||||
session_jump_end: '末尾',
|
||||
session_jump_end_label: 'セッションの末尾へ移動',
|
||||
jump_to_question: '質問へ',
|
||||
jump_to_question_label: 'この回答の質問へ移動',
|
||||
queued_label: '応答後に送信',
|
||||
queued_count: (n) => `${n} 件キュー中`,
|
||||
queued_cancel: 'キューに入れたメッセージをキャンセル',
|
||||
@@ -3678,6 +3684,8 @@ const LOCALES = {
|
||||
session_jump_start_label: 'Перейти к началу сессии',
|
||||
session_jump_end: 'Конец',
|
||||
session_jump_end_label: 'Перейти к концу сессии',
|
||||
jump_to_question: 'к вопросу',
|
||||
jump_to_question_label: 'Перейти к вопросу для этого ответа',
|
||||
queued_label: 'Отправить после ответа',
|
||||
queued_count: (n) => n === 1 ? '1 в очереди' : `${n} в очереди`,
|
||||
queued_cancel: 'Отменить сообщение',
|
||||
@@ -4798,6 +4806,8 @@ const LOCALES = {
|
||||
session_jump_start_label: 'Saltar al inicio de la sesión',
|
||||
session_jump_end: 'Fin',
|
||||
session_jump_end_label: 'Saltar al final de la sesión',
|
||||
jump_to_question: 'a la pregunta',
|
||||
jump_to_question_label: 'Saltar a la pregunta de esta respuesta',
|
||||
queued_label: 'Enviar después de la respuesta',
|
||||
queued_count: (n) => n === 1 ? '1 en cola' : `${n} en cola`,
|
||||
queued_cancel: 'Cancelar mensaje en cola',
|
||||
@@ -5914,6 +5924,8 @@ const LOCALES = {
|
||||
session_jump_start_label: 'Zum Anfang der Sitzung springen',
|
||||
session_jump_end: 'Ende',
|
||||
session_jump_end_label: 'Zum Ende der Sitzung springen',
|
||||
jump_to_question: 'zur Frage',
|
||||
jump_to_question_label: 'Zur Frage dieser Antwort springen',
|
||||
queued_label: 'Wird nach Antwort gesendet',
|
||||
queued_count: (n) => n === 1 ? '1 in Warteschlange' : `${n} in Warteschlange`,
|
||||
queued_cancel: 'Nachricht abbrechen',
|
||||
@@ -7034,6 +7046,8 @@ const LOCALES = {
|
||||
session_jump_start_label: '跳转到会话开头',
|
||||
session_jump_end: '结尾',
|
||||
session_jump_end_label: '跳转到会话结尾',
|
||||
jump_to_question: '回到问题',
|
||||
jump_to_question_label: '跳转到这条回答对应的问题',
|
||||
queued_label: '响应后发送',
|
||||
queued_count: (n) => n === 1 ? '1 条排队' : `${n} 条排队`,
|
||||
queued_cancel: '取消排队消息',
|
||||
@@ -8142,6 +8156,8 @@ const LOCALES = {
|
||||
session_jump_start_label: '跳至會話開頭',
|
||||
session_jump_end: '結尾',
|
||||
session_jump_end_label: '跳至會話結尾',
|
||||
jump_to_question: '回到問題',
|
||||
jump_to_question_label: '跳至這則回答對應的問題',
|
||||
model_unavailable: '\uff08\u4e0d\u53ef\u7528\uff09',
|
||||
model_unavailable_title: '\u6b64\u6a21\u578b\u5df2\u7d93\u4e0d\u5728\u7576\u524d provider \u5217\u8868\u4e2d',
|
||||
provider_mismatch_warning: (m,p)=>`\"${m}\" \u53ef\u80fd\u7121\u6cd5\u5728\u7576\u524d\u914d\u7f6e\u7684\u63d0\u4f9b\u8005 (${p}) \u4e0b\u904b\u4f5c\u3002\u5c1a\u9001\uff0c\u6216\u5728\u7d42\u7aef\u57f7\u884c \`hermes model\` \u5207\u63db\u3002`,
|
||||
@@ -8595,6 +8611,8 @@ const LOCALES = {
|
||||
session_jump_start_label: '跳至會話開頭',
|
||||
session_jump_end: '結尾',
|
||||
session_jump_end_label: '跳至會話結尾',
|
||||
jump_to_question: '回到問題',
|
||||
jump_to_question_label: '跳至這則回答對應的問題',
|
||||
onboarding_api_key_help_prefix: '\u900f\u904e\u4ee5\u4e0b\u65b9\u5f0f\u5132\u5b58\u70ba Hermes .env \u6a94\u6848\u4e2d\u7684\u6a5f\u5bc6',
|
||||
onboarding_api_key_label: 'API \u91d1\u9470',
|
||||
onboarding_api_key_placeholder: '\u7559\u7a7a\u4ee5\u4fdd\u7559\u5df2\u5132\u5b58\u7684\u91d1\u9470',
|
||||
@@ -9286,6 +9304,8 @@ const LOCALES = {
|
||||
session_jump_start_label: 'Ir para o início da sessão',
|
||||
session_jump_end: 'Fim',
|
||||
session_jump_end_label: 'Ir para o fim da sessão',
|
||||
jump_to_question: 'para a pergunta',
|
||||
jump_to_question_label: 'Ir para a pergunta desta resposta',
|
||||
queued_label: 'Envia após a resposta',
|
||||
queued_count: (n) => n === 1 ? '1 na fila' : `${n} na fila`,
|
||||
queued_cancel: 'Cancelar mensagem na fila',
|
||||
@@ -10352,6 +10372,8 @@ const LOCALES = {
|
||||
session_jump_start_label: '세션 시작으로 이동',
|
||||
session_jump_end: '끝',
|
||||
session_jump_end_label: '세션 끝으로 이동',
|
||||
jump_to_question: '질문으로',
|
||||
jump_to_question_label: '이 응답의 질문으로 이동',
|
||||
queued_label: 'Sends after response',
|
||||
queued_count: (n) => n === 1 ? '1 queued' : `${n} queued`,
|
||||
queued_cancel: 'Cancel queued message',
|
||||
@@ -11536,6 +11558,8 @@ const LOCALES = {
|
||||
session_jump_start_label: 'Aller au début de la session',
|
||||
session_jump_end: 'Fin',
|
||||
session_jump_end_label: 'Aller à la fin de la session',
|
||||
jump_to_question: 'à la question',
|
||||
jump_to_question_label: 'Aller à la question de cette réponse',
|
||||
queued_label: 'Envoie après réponse',
|
||||
queued_cancel: 'Annuler le message en file d\'attente',
|
||||
model_unavailable: '(indisponible)',
|
||||
|
||||
@@ -3186,6 +3186,39 @@ main.main.showing-logs > #mainLogs{display:flex;}
|
||||
}
|
||||
.msg-foot .msg-actions { opacity: 1; margin-left: 0; }
|
||||
.msg-foot .msg-time { font-size: 10.5px; opacity: .75; }
|
||||
.msg-question-jump-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-subtle);
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
cursor: pointer;
|
||||
transition: color .12s, border-color .12s, background .12s;
|
||||
}
|
||||
.msg-question-jump-btn:hover,
|
||||
.msg-question-jump-btn:focus-visible {
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
background: var(--hover-bg);
|
||||
outline: none;
|
||||
}
|
||||
.msg-question-highlight .msg-body {
|
||||
animation: question-highlight-pulse 1.6s ease-out;
|
||||
}
|
||||
@keyframes question-highlight-pulse {
|
||||
0% { box-shadow: 0 0 0 0 var(--focus-ring); }
|
||||
35% { box-shadow: 0 0 0 5px var(--focus-ring); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(0,0,0,0); }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.msg-question-jump-btn { display: none; }
|
||||
}
|
||||
.msg-foot-with-usage {
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
|
||||
+51
-1
@@ -350,6 +350,43 @@ async function jumpToSessionStart(){
|
||||
}
|
||||
}
|
||||
|
||||
function _userMessageDomId(rawIdx){
|
||||
return `msg-user-${rawIdx}`;
|
||||
}
|
||||
|
||||
function _questionJumpButtonHtml(questionRawIdx){
|
||||
if(typeof questionRawIdx!=='number'||questionRawIdx<0) return '';
|
||||
const label=t('jump_to_question')||'Question';
|
||||
const title=t('jump_to_question_label')||'Jump to the question for this response';
|
||||
return `<button class="msg-question-jump-btn" type="button" title="${esc(title)}" aria-label="${esc(title)}" onclick="jumpToTurnQuestion(${questionRawIdx})"><span aria-hidden="true">↑</span><span>${esc(label)}</span></button>`;
|
||||
}
|
||||
|
||||
function _highlightQuestionRow(row){
|
||||
if(!row) return;
|
||||
row.classList.remove('msg-question-highlight');
|
||||
void row.offsetWidth;
|
||||
row.classList.add('msg-question-highlight');
|
||||
window.setTimeout(()=>row.classList.remove('msg-question-highlight'),1800);
|
||||
}
|
||||
|
||||
async function jumpToTurnQuestion(questionRawIdx){
|
||||
const container=$('messages');
|
||||
if(!container||typeof questionRawIdx!=='number'||questionRawIdx<0) return;
|
||||
const scrollToTarget=()=>{
|
||||
const row=document.getElementById(_userMessageDomId(questionRawIdx));
|
||||
if(!row) return false;
|
||||
row.scrollIntoView({block:'center',behavior:'smooth'});
|
||||
_highlightQuestionRow(row);
|
||||
return true;
|
||||
};
|
||||
if(scrollToTarget()) return;
|
||||
if(_messageHiddenBeforeCount()>0){
|
||||
_messageRenderWindowSize=Math.max(_currentMessageRenderWindowSize(),_messageRenderableMessageCount());
|
||||
renderMessages({ preserveScroll:true });
|
||||
requestAnimationFrame(scrollToTarget);
|
||||
}
|
||||
}
|
||||
|
||||
const DASHBOARD_STATUS_TTL_MS=60000;
|
||||
let _dashboardStatusCache=null;
|
||||
let _dashboardStatusFetchedAt=0;
|
||||
@@ -5288,6 +5325,13 @@ function renderMessages(options){
|
||||
}
|
||||
let _prevSepKey=null;
|
||||
let currentAssistantTurn=null;
|
||||
const questionRawIdxByAssistantRawIdx=new Map();
|
||||
let lastQuestionRawIdx=-1;
|
||||
for(const entry of visWithIdx){
|
||||
const role=entry&&entry.m&&entry.m.role;
|
||||
if(role==='user') lastQuestionRawIdx=entry.rawIdx;
|
||||
else if(role==='assistant') questionRawIdxByAssistantRawIdx.set(entry.rawIdx,lastQuestionRawIdx);
|
||||
}
|
||||
const assistantSegments=new Map();
|
||||
const assistantThinking=new Map();
|
||||
const userRows=new Map();
|
||||
@@ -5340,6 +5384,8 @@ function renderMessages(options){
|
||||
const isUser=m.role==='user';
|
||||
const displayContent=isUser?_stripWorkspaceDisplayPrefix(content):content;
|
||||
const isLastAssistant=!isUser&&vi===renderVisWithIdx.length-1;
|
||||
const nextRendered=renderVisWithIdx[vi+1];
|
||||
const isTurnFinalAssistant=!isUser&&(!nextRendered||!nextRendered.m||nextRendered.m.role!=='assistant');
|
||||
let filesHtml='';
|
||||
if(m.attachments&&m.attachments.length){
|
||||
// Static regression tests intentionally look for msg-media-img/msg-file-badge near this branch.
|
||||
@@ -5372,7 +5418,10 @@ function renderMessages(options){
|
||||
const tsTitle=tsVal?(_fmtSv?_fmtSv(new Date(tsVal*1000),{}):new Date(tsVal*1000).toLocaleString()):'';
|
||||
const tsTime=_formatMessageFooterTimestamp(tsVal);
|
||||
const timeHtml = tsTime ? `<span class="msg-time" title="${esc(tsTitle)}">${tsTime}</span>` : '';
|
||||
const footHtml = `<div class="msg-foot">${timeHtml}<span class="msg-actions">${editBtn}${ttsBtn}${forkBtn}${copyBtn}${retryBtn}</span></div>`;
|
||||
const questionJumpBtn = (!isUser&&!m._live&&isTurnFinalAssistant)
|
||||
? _questionJumpButtonHtml(questionRawIdxByAssistantRawIdx.get(rawIdx))
|
||||
: '';
|
||||
const footHtml = `<div class="msg-foot">${timeHtml}<span class="msg-actions">${editBtn}${ttsBtn}${forkBtn}${copyBtn}${retryBtn}</span>${questionJumpBtn}</div>`;
|
||||
|
||||
if(_isContextCompactionMessage(m)){
|
||||
if(compressionState || referenceNode){
|
||||
@@ -5392,6 +5441,7 @@ function renderMessages(options){
|
||||
currentAssistantTurn=null;
|
||||
const row=document.createElement('div');
|
||||
row.className='msg-row';
|
||||
row.id=_userMessageDomId(rawIdx);
|
||||
row.dataset.msgIdx=rawIdx;
|
||||
row.dataset.role='user';
|
||||
row.dataset.rawText=String(displayContent).trim();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Regression coverage for #2246 per-turn jump-to-question buttons."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
|
||||
I18N_JS = (REPO / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_assistant_footer_gets_completed_turn_question_jump_button():
|
||||
assert "function _questionJumpButtonHtml(questionRawIdx)" in UI_JS
|
||||
assert "function jumpToTurnQuestion(questionRawIdx)" in UI_JS
|
||||
assert "const questionRawIdxByAssistantRawIdx=new Map()" in UI_JS
|
||||
assert "questionRawIdxByAssistantRawIdx.set(entry.rawIdx,lastQuestionRawIdx)" in UI_JS
|
||||
assert "row.id=_userMessageDomId(rawIdx)" in UI_JS
|
||||
assert "const isTurnFinalAssistant=!isUser&&(!nextRendered||!nextRendered.m||nextRendered.m.role!=='assistant')" in UI_JS
|
||||
assert "(!isUser&&!m._live&&isTurnFinalAssistant)" in UI_JS
|
||||
assert "_questionJumpButtonHtml(questionRawIdxByAssistantRawIdx.get(rawIdx))" in UI_JS
|
||||
assert "msg-question-jump-btn" in UI_JS
|
||||
|
||||
|
||||
def test_question_jump_expands_windowed_history_and_highlights_question():
|
||||
assert "_messageRenderWindowSize=Math.max(_currentMessageRenderWindowSize(),_messageRenderableMessageCount())" in UI_JS
|
||||
assert "renderMessages({ preserveScroll:true })" in UI_JS
|
||||
assert "row.scrollIntoView({block:'center',behavior:'smooth'})" in UI_JS
|
||||
assert "_highlightQuestionRow(row)" in UI_JS
|
||||
assert "msg-question-highlight" in UI_JS
|
||||
|
||||
|
||||
def test_question_jump_button_is_quiet_and_hidden_on_mobile():
|
||||
assert ".msg-question-jump-btn" in STYLE_CSS
|
||||
assert "margin-left: auto;" in STYLE_CSS
|
||||
assert ".msg-question-highlight .msg-body" in STYLE_CSS
|
||||
assert "@keyframes question-highlight-pulse" in STYLE_CSS
|
||||
assert "@media (max-width: 600px)" in STYLE_CSS
|
||||
assert ".msg-question-jump-btn { display: none; }" in STYLE_CSS
|
||||
|
||||
|
||||
def test_question_jump_text_is_localized():
|
||||
for key in ("jump_to_question", "jump_to_question_label"):
|
||||
assert I18N_JS.count(f"{key}:") >= 12
|
||||
Reference in New Issue
Block a user