@bitseek/hermes-webui 0.1.0-beta.0 → 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/vendor/agent-frontend-shell/.bitseek-source.json +2 -2
- package/vendor/agent-frontend-shell/CHANGELOG.md +178 -1
- package/vendor/agent-frontend-shell/CONTRIBUTORS.md +5 -5
- package/vendor/agent-frontend-shell/api/agent_health.py +134 -0
- package/vendor/agent-frontend-shell/api/config.py +145 -104
- package/vendor/agent-frontend-shell/api/gateway_chat.py +56 -12
- package/vendor/agent-frontend-shell/api/helpers.py +4 -2
- package/vendor/agent-frontend-shell/api/models.py +202 -20
- package/vendor/agent-frontend-shell/api/paths.py +77 -0
- package/vendor/agent-frontend-shell/api/plugins.py +185 -0
- package/vendor/agent-frontend-shell/api/profiles.py +95 -16
- package/vendor/agent-frontend-shell/api/routes.py +831 -30
- package/vendor/agent-frontend-shell/api/run_journal.py +1 -0
- package/vendor/agent-frontend-shell/api/state_sync.py +5 -4
- package/vendor/agent-frontend-shell/api/streaming.py +211 -56
- package/vendor/agent-frontend-shell/api/todo_state.py +122 -0
- package/vendor/agent-frontend-shell/api/updates.py +30 -3
- package/vendor/agent-frontend-shell/api/upload.py +251 -18
- package/vendor/agent-frontend-shell/api/workspace.py +323 -65
- package/vendor/agent-frontend-shell/bitseek_docs/BitSeek_Claw_Operation_Manual_EN.docx +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/BitSeek_Claw_Operation_Manual_ZH.docx +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/00-Installation.md +174 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/01-Overview.md +128 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/02-Page-Operations.md +461 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/README.md +61 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/ai-colleagues.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/chat-area.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/kanban.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/main-page.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-notes.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-overview.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-profile.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-soul.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/navigation-bar.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-appearance.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-conversation.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-overview.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-plugins.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-preferences.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-providers.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-system.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/sidebar.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/skills.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/tasks.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/workspace-panel.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/md_to_docx.py +351 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/00-/345/256/211/350/243/205/345/220/257/345/212/250.md +174 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/01-/346/225/264/344/275/223/346/246/202/350/247/210.md +128 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/02-/351/241/265/351/235/242/346/223/215/344/275/234.md +463 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/README.md +61 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/ai-colleagues.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/chat-area.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/kanban.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/main-page.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-notes.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-overview.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-profile.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-soul.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/navigation-bar.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-appearance.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-conversation.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-overview.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-plugins.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-preferences.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-providers.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-system.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/sidebar.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/skills.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/tasks.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/workspace-panel.png +0 -0
- package/vendor/agent-frontend-shell/build-release.sh +62 -0
- package/vendor/agent-frontend-shell/ctl.sh +1 -0
- package/vendor/agent-frontend-shell/docker-compose.local.yml +33 -0
- package/vendor/agent-frontend-shell/docker-compose.yml +8 -0
- package/vendor/agent-frontend-shell/docker_init.bash +1 -0
- package/vendor/agent-frontend-shell/docs/rfcs/hermes-run-adapter-contract.md +74 -15
- package/vendor/agent-frontend-shell/extensions/common/index.css +6 -0
- package/vendor/agent-frontend-shell/extensions/manifest.json +6 -0
- package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.js +60 -14
- package/vendor/agent-frontend-shell/readme-simple.md +103 -0
- package/vendor/agent-frontend-shell/requirements.txt +5 -0
- package/vendor/agent-frontend-shell/server.py +7 -0
- package/vendor/agent-frontend-shell/static/boot.js +53 -1
- package/vendor/agent-frontend-shell/static/commands.js +20 -10
- package/vendor/agent-frontend-shell/static/i18n.js +1142 -1016
- package/vendor/agent-frontend-shell/static/index.html +13 -3
- package/vendor/agent-frontend-shell/static/messages.js +48 -3
- package/vendor/agent-frontend-shell/static/panels.js +199 -30
- package/vendor/agent-frontend-shell/static/sessions.js +249 -39
- package/vendor/agent-frontend-shell/static/style.css +46 -2
- package/vendor/agent-frontend-shell/static/ui.js +323 -79
- package/vendor/agent-frontend-shell/static/workspace.js +185 -7
- package/vendor/agent-frontend-shell/README-CUSTOM.md +0 -76
- package/vendor/agent-frontend-shell/docker-compose.custom.yml +0 -26
|
@@ -268,12 +268,15 @@ let _messageRenderWindowSize=MESSAGE_RENDER_WINDOW_DEFAULT;
|
|
|
268
268
|
// Cached visWithIdx array — invalidated when S.messages.length changes.
|
|
269
269
|
let _visWithIdxCache=null;
|
|
270
270
|
let _visWithIdxCacheLen=0;
|
|
271
|
+
function clearVisibleMessageRowCache(){
|
|
272
|
+
_visWithIdxCache=null;
|
|
273
|
+
_visWithIdxCacheLen=0;
|
|
274
|
+
}
|
|
271
275
|
function _resetMessageRenderWindow(sid){
|
|
272
276
|
_messageRenderWindowSid=sid||null;
|
|
273
277
|
_messageRenderWindowSize=MESSAGE_RENDER_WINDOW_DEFAULT;
|
|
274
278
|
_clearRenderCache();
|
|
275
|
-
|
|
276
|
-
_visWithIdxCacheLen=0;
|
|
279
|
+
clearVisibleMessageRowCache();
|
|
277
280
|
}
|
|
278
281
|
|
|
279
282
|
// ── renderMd / _renderUserFencedBlocks cache ──────────────────────────────
|
|
@@ -1060,6 +1063,20 @@ function _findModelInDropdown(modelId, sel, preferredProviderId){
|
|
|
1060
1063
|
if(!modelId||!sel) return null;
|
|
1061
1064
|
const options=Array.from(sel.options);
|
|
1062
1065
|
const opts=options.map(o=>o.value);
|
|
1066
|
+
// 0. Exact match — highest priority when it doesn't conflict with a
|
|
1067
|
+
// cross-provider preference (#3360, guarded for #1228/#1313).
|
|
1068
|
+
// When all models share the same provider (e.g. a custom proxy),
|
|
1069
|
+
// normalization can collapse distinct multi-slash IDs to the same key
|
|
1070
|
+
// and options.find() returns whichever appears first in the DOM instead
|
|
1071
|
+
// of the exact value. But when the exact option belongs to a *different*
|
|
1072
|
+
// provider than the preferred one, we must fall through to the provider-
|
|
1073
|
+
// aware match so rehydration doesn't snap to the wrong provider row.
|
|
1074
|
+
if(opts.includes(modelId)){
|
|
1075
|
+
const exactOpt=options.find(o=>o.value===modelId);
|
|
1076
|
+
const exactProv=exactOpt?_getOptionProviderId(exactOpt).toLowerCase():'';
|
|
1077
|
+
const pref=String(preferredProviderId||'').toLowerCase();
|
|
1078
|
+
if(!pref || !exactProv || exactProv===pref) return modelId;
|
|
1079
|
+
}
|
|
1063
1080
|
// 1. Normalize: lowercase, strip namespace prefix, replace hyphens→dots.
|
|
1064
1081
|
// Also strip @provider: prefix from deduplicated model IDs (#1228, #1313).
|
|
1065
1082
|
const norm=s=>s.toLowerCase().replace(/^[^/]+\//,'').replace(/^@([^:]+:)+/,'').replace(/-/g,'.');
|
|
@@ -1074,8 +1091,7 @@ function _findModelInDropdown(modelId, sel, preferredProviderId){
|
|
|
1074
1091
|
const providerMatch=options.find(o=>norm(o.value)===target && _getOptionProviderId(o).toLowerCase()===preferred);
|
|
1075
1092
|
if(providerMatch) return providerMatch.value;
|
|
1076
1093
|
}
|
|
1077
|
-
// 2.
|
|
1078
|
-
if(opts.includes(modelId)) return modelId;
|
|
1094
|
+
// 2. Normalized match
|
|
1079
1095
|
const exact=opts.find(o=>norm(o)===target);
|
|
1080
1096
|
if(exact) return exact;
|
|
1081
1097
|
// If the request is provider-qualified (either explicit @provider:model or
|
|
@@ -1418,7 +1434,26 @@ function _normalizeConfiguredModelKey(modelId){
|
|
|
1418
1434
|
// Defensive: trailing-colon / trailing-slash falls back to the original key
|
|
1419
1435
|
// so malformed configs don't collapse distinct ids to '' (matches backend _norm_model_id).
|
|
1420
1436
|
if(s.startsWith('@')&&s.includes(':')){const last=s.split(':').pop();s=last||s;}
|
|
1421
|
-
|
|
1437
|
+
// Skip slash-based stripping for URI-scheme IDs (e.g. gpt://folder/model)
|
|
1438
|
+
// whose slashes are path separators, not provider delimiters (#3429).
|
|
1439
|
+
const _hasScheme=/^[a-z][a-z0-9+.-]*:\/\//i.test(s);
|
|
1440
|
+
if(!_hasScheme){
|
|
1441
|
+
// Strip provider-qualified prefixes that contain colons before the first
|
|
1442
|
+
// slash (e.g. 'custom:llm-proxy/model' → 'model'). Without this, badge-
|
|
1443
|
+
// key variants like 'custom:llm-proxy/opencode_go/deepseek-v4-pro' and the
|
|
1444
|
+
// bare 'opencode_go/deepseek-v4-pro' produce different normalized keys and
|
|
1445
|
+
// aren't deduped in the configured section (#3360).
|
|
1446
|
+
if(s.includes('/')&&s.indexOf(':')!==-1&&s.indexOf(':')<s.indexOf('/')){
|
|
1447
|
+
s=s.slice(s.indexOf('/')+1)||s;
|
|
1448
|
+
}
|
|
1449
|
+
// Strip only the first slash-segment (provider prefix), preserving any
|
|
1450
|
+
// remaining vendor hierarchy. Using split('/').pop() here previously
|
|
1451
|
+
// discarded ALL segments except the last, collapsing distinct multi-slash
|
|
1452
|
+
// IDs like 'vendor_a/deepseek-v4-pro' and 'vendor_b/deepseek/deepseek-v4-pro'
|
|
1453
|
+
// to the same key, causing badge misattribution and configured-entry
|
|
1454
|
+
// suppression (#3360).
|
|
1455
|
+
if(s.includes('/')) s=s.replace(/^[^/]+\//, '')||s;
|
|
1456
|
+
}
|
|
1422
1457
|
return s.replace(/-/g,'.');
|
|
1423
1458
|
}
|
|
1424
1459
|
|
|
@@ -2197,58 +2232,32 @@ window.addEventListener('resize',function(){
|
|
|
2197
2232
|
});
|
|
2198
2233
|
|
|
2199
2234
|
// ── Scroll pinning ──────────────────────────────────────────────────────────
|
|
2200
|
-
// When streaming, auto-scroll only
|
|
2201
|
-
//
|
|
2202
|
-
//
|
|
2203
|
-
//
|
|
2204
|
-
// the user's explicit scroll-up. Fixes #1469 / #1360.
|
|
2205
|
-
// Direction-aware unpin (issue #1731): the hysteresis below is correct
|
|
2206
|
-
// for re-pinning (entering the near-bottom zone), but applying it to
|
|
2207
|
-
// unpinning stranded users who scrolled up by a small amount inside the
|
|
2208
|
-
// 250px zone — every upward sample still landed in the near-bottom
|
|
2209
|
-
// region, so the counter kept incrementing and _scrollPinned stayed
|
|
2210
|
-
// true. The next streaming token snapped them back. We now track
|
|
2211
|
-
// scrollTop direction: an explicit upward movement (scrollTop decreased
|
|
2212
|
-
// by more than 2px between samples) unpins immediately and resets the
|
|
2213
|
-
// counter, while downward / stationary movement falls through the
|
|
2214
|
-
// original hysteresis path so the macOS momentum re-pin protection from
|
|
2215
|
-
// #1360 is preserved.
|
|
2216
|
-
// rAF-debounced scroll listener (issue #1360): on macOS WKWebView, trackpad
|
|
2217
|
-
// momentum scrolling fires scroll events that interleave with the
|
|
2218
|
-
// _programmaticScroll setTimeout(0) guard. A mid-momentum scroll event can
|
|
2219
|
-
// either get swallowed (_programmaticScroll still true) or falsely report
|
|
2220
|
-
// the user is at the bottom (momentum hasn't settled). rAF defers the
|
|
2221
|
-
// distance check to the next paint frame when the browser's scroll
|
|
2222
|
-
// position has settled. A hysteresis counter requires two consecutive
|
|
2223
|
-
// near-bottom samples before re-pinning, preventing accidental re-pin
|
|
2224
|
-
// during initial deceleration.
|
|
2235
|
+
// When streaming, auto-scroll only while the user is following the live tail.
|
|
2236
|
+
// Any manual scroll up sets a sticky unpinned flag until the user scrolls back
|
|
2237
|
+
// to the bottom (near-bottom hysteresis on downward motion) or clicks ↓.
|
|
2238
|
+
// Programmatic scrolls are ignored via _programmaticScroll. Fixes #1469 / #1360 / #1731.
|
|
2225
2239
|
let _scrollPinned=true;
|
|
2226
2240
|
let _programmaticScroll=false;
|
|
2227
2241
|
let _nearBottomCount=0;
|
|
2228
2242
|
let _lastScrollTop=null;
|
|
2229
|
-
|
|
2230
|
-
|
|
2243
|
+
// Sticky-unpin model (#3343 supersedes #3330's proximity re-pin): once the user
|
|
2244
|
+
// scrolls up, streaming stops auto-following until they return to the bottom or
|
|
2245
|
+
// click ↓. The upward-intent TIMEOUT mechanism (_lastMessageUpwardIntentMs /
|
|
2246
|
+
// MESSAGE_UPWARD_INTENT_MS) is removed — sticky-unpin makes it unnecessary.
|
|
2247
|
+
// Keep the non-message intent timestamp at -Infinity so load-time isn't read as
|
|
2248
|
+
// intent (the #3330 follow-up fix); 0 would mark the first NON_MESSAGE_SCROLL_INTENT
|
|
2249
|
+
// window after load as suppressed.
|
|
2250
|
+
let _lastNonMessageScrollIntentMs=-Infinity;
|
|
2231
2251
|
let _messageUserUnpinned=false;
|
|
2232
2252
|
let _bottomSettleToken=0;
|
|
2233
2253
|
const NON_MESSAGE_SCROLL_INTENT_SUPPRESS_MS=350;
|
|
2234
|
-
const MESSAGE_UPWARD_INTENT_MS=2000;
|
|
2235
2254
|
function _cancelBottomSettle(){ _bottomSettleToken++; }
|
|
2236
2255
|
function _recordNonMessageScrollIntent(e){
|
|
2237
2256
|
const el=document.getElementById('messages');
|
|
2238
2257
|
const target=e&&e.target;
|
|
2239
2258
|
if(!el||!target) return;
|
|
2240
|
-
// Streaming token renders should keep pinning the chat only while the user is
|
|
2241
|
-
// actually interacting with the chat pane. A wheel/touch gesture over the
|
|
2242
|
-
// session sidebar (or another independent pane) must not be immediately fought
|
|
2243
|
-
// by scrollIfPinned() writing #messages.scrollTop on the next token (#1784).
|
|
2244
2259
|
if(!el.contains(target)) _lastNonMessageScrollIntentMs=performance.now();
|
|
2245
2260
|
else if(e.type==='touchmove'||(typeof e.deltaY==='number'&&e.deltaY<0)){
|
|
2246
|
-
// User is intentionally moving upward in the transcript. Record the real
|
|
2247
|
-
// input event so later scrollTop decreases caused by layout/windowing do
|
|
2248
|
-
// not masquerade as user intent and strand live streaming away from bottom.
|
|
2249
|
-
_lastMessageUpwardIntentMs=performance.now();
|
|
2250
|
-
// User is intentionally moving in the transcript. Cancel any delayed
|
|
2251
|
-
// scrollToBottom settling that was scheduled by session-load/layout growth.
|
|
2252
2261
|
_cancelBottomSettle();
|
|
2253
2262
|
if(typeof e.deltaY==='number'&&e.deltaY<0){
|
|
2254
2263
|
_messageUserUnpinned=true;
|
|
@@ -2257,9 +2266,6 @@ function _recordNonMessageScrollIntent(e){
|
|
|
2257
2266
|
}
|
|
2258
2267
|
}
|
|
2259
2268
|
}
|
|
2260
|
-
function _recentMessageUpwardIntent(){
|
|
2261
|
-
return performance.now()-_lastMessageUpwardIntentMs<MESSAGE_UPWARD_INTENT_MS;
|
|
2262
|
-
}
|
|
2263
2269
|
function _recentNonMessageScrollIntent(){
|
|
2264
2270
|
return performance.now()-_lastNonMessageScrollIntentMs<NON_MESSAGE_SCROLL_INTENT_SUPPRESS_MS;
|
|
2265
2271
|
}
|
|
@@ -2270,8 +2276,23 @@ if(typeof document!=='undefined'){
|
|
|
2270
2276
|
// Reset hook for session-switch — called from sessions.js loadSession() to
|
|
2271
2277
|
// prevent the new chat's first scroll comparing against the previous chat's
|
|
2272
2278
|
// scrollTop (Opus stage-302 SHOULD-FIX, #1731 follow-up).
|
|
2273
|
-
function _resetScrollDirectionTracker(){
|
|
2274
|
-
|
|
2279
|
+
function _resetScrollDirectionTracker(){
|
|
2280
|
+
_lastScrollTop=null;
|
|
2281
|
+
_messageUserUnpinned=false;
|
|
2282
|
+
_scrollPinned=true;
|
|
2283
|
+
_nearBottomCount=0;
|
|
2284
|
+
}
|
|
2285
|
+
function _resetStreamScrollFollow(){
|
|
2286
|
+
_messageUserUnpinned=false;
|
|
2287
|
+
_scrollPinned=true;
|
|
2288
|
+
_nearBottomCount=0;
|
|
2289
|
+
_lastScrollTop=null;
|
|
2290
|
+
_cancelBottomSettle();
|
|
2291
|
+
}
|
|
2292
|
+
if(typeof window!=='undefined'){
|
|
2293
|
+
window._resetScrollDirectionTracker=_resetScrollDirectionTracker;
|
|
2294
|
+
window._resetStreamScrollFollow=_resetStreamScrollFollow;
|
|
2295
|
+
}
|
|
2275
2296
|
/* ── Pull-to-refresh for PWA standalone (Android) ── */
|
|
2276
2297
|
(function(){
|
|
2277
2298
|
if(typeof document==='undefined') return;
|
|
@@ -2351,17 +2372,32 @@ if(typeof window!=='undefined') window._resetScrollDirectionTracker=_resetScroll
|
|
|
2351
2372
|
_scrollRaf=requestAnimationFrame(()=>{
|
|
2352
2373
|
const top=el.scrollTop;
|
|
2353
2374
|
const nearBottom=el.scrollHeight-top-el.clientHeight<250;
|
|
2354
|
-
|
|
2355
|
-
const
|
|
2375
|
+
const movedUp=_lastScrollTop!==null&&top<_lastScrollTop-2;
|
|
2376
|
+
const movedDown=_lastScrollTop!==null&&top>_lastScrollTop+2;
|
|
2356
2377
|
_lastScrollTop=top;
|
|
2357
|
-
if(movedUp){
|
|
2358
|
-
|
|
2378
|
+
if(movedUp){
|
|
2379
|
+
_cancelBottomSettle();
|
|
2380
|
+
_nearBottomCount=0;
|
|
2381
|
+
_scrollPinned=false;
|
|
2382
|
+
_messageUserUnpinned=true;
|
|
2383
|
+
}else if(movedDown&&nearBottom){
|
|
2384
|
+
_nearBottomCount=_nearBottomCount+1;
|
|
2385
|
+
if(_nearBottomCount>=2){
|
|
2386
|
+
_scrollPinned=true;
|
|
2387
|
+
_messageUserUnpinned=false;
|
|
2388
|
+
}
|
|
2389
|
+
}else if(!_messageUserUnpinned){
|
|
2359
2390
|
if(nearBottom){
|
|
2360
2391
|
_nearBottomCount=_nearBottomCount+1;
|
|
2361
2392
|
if(_nearBottomCount>=2) _scrollPinned=true;
|
|
2362
|
-
}
|
|
2363
|
-
|
|
2364
|
-
|
|
2393
|
+
}else{
|
|
2394
|
+
_nearBottomCount=0;
|
|
2395
|
+
_scrollPinned=false;
|
|
2396
|
+
}
|
|
2397
|
+
}else if(!nearBottom){
|
|
2398
|
+
_nearBottomCount=0;
|
|
2399
|
+
_scrollPinned=false;
|
|
2400
|
+
}
|
|
2365
2401
|
const btn=$('scrollToBottomBtn');
|
|
2366
2402
|
const showBottomButton=!_scrollPinned && el.scrollHeight-top-el.clientHeight>80;
|
|
2367
2403
|
if(btn) btn.style.display=showBottomButton?'flex':'none';
|
|
@@ -2758,13 +2794,34 @@ function _setMessageScrollToBottom(){
|
|
|
2758
2794
|
_lastScrollTop=el.scrollTop;
|
|
2759
2795
|
_nearBottomCount=2;
|
|
2760
2796
|
_scrollPinned=true;
|
|
2761
|
-
requestAnimationFrame(()=>{
|
|
2797
|
+
requestAnimationFrame(()=>{
|
|
2798
|
+
// Retry the bottom write on the next layout frame so a DOM rebuild that
|
|
2799
|
+
// grows the transcript after the first write doesn't strand a pinned
|
|
2800
|
+
// conversation mid-scroll (#3319). But by this frame the user may have
|
|
2801
|
+
// scrolled up — under the sticky-unpin model (#3343) _messageUserUnpinned
|
|
2802
|
+
// is the authoritative "user scrolled away" signal, so DON'T snap them back
|
|
2803
|
+
// or re-pin if so; only release the programmatic-scroll latch.
|
|
2804
|
+
if(_messageUserUnpinned || !_scrollPinned || _recentNonMessageScrollIntent()){
|
|
2805
|
+
requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); });
|
|
2806
|
+
return;
|
|
2807
|
+
}
|
|
2808
|
+
el.scrollTop=el.scrollHeight;
|
|
2809
|
+
_lastScrollTop=el.scrollTop;
|
|
2810
|
+
_nearBottomCount=2;
|
|
2811
|
+
_scrollPinned=true;
|
|
2812
|
+
requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); });
|
|
2813
|
+
});
|
|
2762
2814
|
}
|
|
2763
2815
|
function _isMessagePaneNearBottom(threshold=250){
|
|
2764
2816
|
const el=$('messages');
|
|
2765
2817
|
if(!el) return false;
|
|
2766
2818
|
return el.scrollHeight-el.scrollTop-el.clientHeight<=threshold;
|
|
2767
2819
|
}
|
|
2820
|
+
function _messageBottomDistance(){
|
|
2821
|
+
const el=$('messages');
|
|
2822
|
+
if(!el) return 0;
|
|
2823
|
+
return el.scrollHeight-el.scrollTop-el.clientHeight;
|
|
2824
|
+
}
|
|
2768
2825
|
function _shouldFollowMessagesOnDomReplace(){
|
|
2769
2826
|
return !_messageUserUnpinned && (_scrollPinned || _isMessagePaneNearBottom(1200));
|
|
2770
2827
|
}
|
|
@@ -2778,21 +2835,23 @@ function _settleMessageScrollToBottom(force){
|
|
|
2778
2835
|
const passes=[0,16,80,180];
|
|
2779
2836
|
passes.forEach(delay=>setTimeout(()=>{
|
|
2780
2837
|
if(token!==_bottomSettleToken) return;
|
|
2781
|
-
if(!force && (!_scrollPinned||_recentNonMessageScrollIntent())) return;
|
|
2838
|
+
if(!force && (!_scrollPinned||_messageUserUnpinned||_recentNonMessageScrollIntent())) return;
|
|
2782
2839
|
_setMessageScrollToBottom();
|
|
2783
2840
|
},delay));
|
|
2784
2841
|
requestAnimationFrame(()=>{
|
|
2785
2842
|
if(token!==_bottomSettleToken) return;
|
|
2786
|
-
if(force || (_scrollPinned&&!_recentNonMessageScrollIntent())) _setMessageScrollToBottom();
|
|
2843
|
+
if(force || (_scrollPinned&&!_messageUserUnpinned&&!_recentNonMessageScrollIntent())) _setMessageScrollToBottom();
|
|
2787
2844
|
requestAnimationFrame(()=>{
|
|
2788
2845
|
if(token!==_bottomSettleToken) return;
|
|
2789
|
-
if(force || (_scrollPinned&&!_recentNonMessageScrollIntent())) _setMessageScrollToBottom();
|
|
2846
|
+
if(force || (_scrollPinned&&!_messageUserUnpinned&&!_recentNonMessageScrollIntent())) _setMessageScrollToBottom();
|
|
2790
2847
|
});
|
|
2791
2848
|
});
|
|
2792
2849
|
}
|
|
2793
2850
|
function scrollIfPinned(){
|
|
2851
|
+
if(_messageUserUnpinned) return;
|
|
2794
2852
|
if(!_scrollPinned) return;
|
|
2795
2853
|
if(_recentNonMessageScrollIntent()) return;
|
|
2854
|
+
if(_messageBottomDistance()>500) _setMessageScrollToBottom();
|
|
2796
2855
|
_settleMessageScrollToBottom(false);
|
|
2797
2856
|
}
|
|
2798
2857
|
function scrollToBottom(){
|
|
@@ -2836,7 +2895,7 @@ function getModelLabel(modelId){
|
|
|
2836
2895
|
if(rawId.startsWith('@custom:')){
|
|
2837
2896
|
const rest=rawId.slice('@custom:'.length);
|
|
2838
2897
|
if(rest.includes(':')) return rest.slice(rest.lastIndexOf(':')+1)||rawId;
|
|
2839
|
-
if(rest.includes('/')) return rest.
|
|
2898
|
+
if(rest.includes('/')) return rest.slice(rest.indexOf('/')+1)||rawId;
|
|
2840
2899
|
return rest||rawId;
|
|
2841
2900
|
}
|
|
2842
2901
|
// Check dynamic labels first, then fall back to splitting the ID
|
|
@@ -2844,8 +2903,47 @@ function getModelLabel(modelId){
|
|
|
2844
2903
|
// Static fallback for common models
|
|
2845
2904
|
const STATIC_LABELS={'openai/gpt-5.4-mini':'GPT-5.4 Mini','openai/gpt-4o':'GPT-4o','openai/o3':'o3','openai/o4-mini':'o4-mini','anthropic/claude-sonnet-4.6':'Sonnet 4.6','anthropic/claude-sonnet-4-5':'Sonnet 4.5','anthropic/claude-haiku-3-5':'Haiku 3.5','google/gemini-3.1-pro-preview':'Gemini 3.1 Pro','google/gemini-3-flash-preview':'Gemini 3 Flash','google/gemini-3.1-flash-lite-preview':'Gemini 3.1 Flash Lite','google/gemini-2.5-pro':'Gemini 2.5 Pro','google/gemini-2.5-flash':'Gemini 2.5 Flash','deepseek/deepseek-v4-flash':'DeepSeek V4 Flash','deepseek/deepseek-v4-pro':'DeepSeek V4 Pro','deepseek/deepseek-chat-v3-0324':'DeepSeek V3 (legacy)','meta-llama/llama-4-scout':'Llama 4 Scout'};
|
|
2846
2905
|
if(STATIC_LABELS[modelId]) return STATIC_LABELS[modelId];
|
|
2847
|
-
// Safe Ollama-tag fallback
|
|
2848
|
-
|
|
2906
|
+
// Safe Ollama-tag fallback: strip only the first slash-segment (provider
|
|
2907
|
+
// prefix) so multi-slash IDs preserve their vendor hierarchy (#3360).
|
|
2908
|
+
// URI-scheme ids (e.g. `gpt://${FOLDER}/deepseek-v4-flash/latest`, provider
|
|
2909
|
+
// `yandex:gpt`) must NOT be first-segment-stripped — `indexOf('/')` would
|
|
2910
|
+
// land inside the `://` and leave `/${FOLDER}/...` path junk (#3429). For a
|
|
2911
|
+
// `scheme://authority/path...` id, drop the scheme AND the authority, then
|
|
2912
|
+
// pick the model name from the PATH segments only. A version/channel tail
|
|
2913
|
+
// (`latest`/`stable`/numeric) is skipped only when a real model segment
|
|
2914
|
+
// precedes it — never promoting the authority or a container folder (#3429).
|
|
2915
|
+
let _last;
|
|
2916
|
+
const _uriMatch = /^[a-z][a-z0-9+.-]*:\/\/(.+)$/i.exec(modelId);
|
|
2917
|
+
if (_uriMatch) {
|
|
2918
|
+
const _all = _uriMatch[1].split('/').filter(Boolean);
|
|
2919
|
+
// _all[0] is the authority (folder/host); the model lives in the path tail.
|
|
2920
|
+
const _path = _all.slice(1);
|
|
2921
|
+
// A pure version/channel tail: named channels, or a bare version number
|
|
2922
|
+
// (`v4`, `1.2`, `20231231`) — NOT a mixed model name that merely starts
|
|
2923
|
+
// with a digit (`2026-model`, `4o-mini`), which must be kept as the label.
|
|
2924
|
+
const _isVersionTail = (s) => /^(latest|stable|current|default|v\d[\d.]*|\d[\d.]*)$/i.test(s);
|
|
2925
|
+
const _isPlaceholder = (s) => /\$\{[^}]*\}/.test(s);
|
|
2926
|
+
// Walk path segments right-to-left; the model name is the LAST segment that
|
|
2927
|
+
// is neither a version/channel tail (`latest`, `v4`, `1.2`) nor a `${...}`
|
|
2928
|
+
// env-var placeholder. Fall back to the last non-placeholder segment, then
|
|
2929
|
+
// the literal last segment. Never returns the authority (`_all[0]`).
|
|
2930
|
+
let _pick = '';
|
|
2931
|
+
let _lastUsable = '';
|
|
2932
|
+
for (let _i = _path.length - 1; _i >= 0; _i--) {
|
|
2933
|
+
const _seg = _path[_i];
|
|
2934
|
+
if (_isPlaceholder(_seg)) continue;
|
|
2935
|
+
if (!_lastUsable) _lastUsable = _seg;
|
|
2936
|
+
if (!_isVersionTail(_seg)) { _pick = _seg; break; }
|
|
2937
|
+
}
|
|
2938
|
+
// Fallbacks: the chosen non-version segment, else the last non-placeholder
|
|
2939
|
+
// path segment. NEVER the authority and NEVER a `${...}` placeholder — for
|
|
2940
|
+
// a degenerate id (`gpt://folder123`, `gpt://folder123/${MODEL}`) fall all
|
|
2941
|
+
// the way back to the raw id rather than leak the folder/host or env var.
|
|
2942
|
+
const _lastPath = _path[_path.length - 1] || '';
|
|
2943
|
+
_last = _pick || _lastUsable || (_lastPath && !_isPlaceholder(_lastPath) ? _lastPath : '') || modelId;
|
|
2944
|
+
} else {
|
|
2945
|
+
_last = modelId.includes('/') ? (modelId.slice(modelId.indexOf('/')+1) || modelId) : modelId;
|
|
2946
|
+
}
|
|
2849
2947
|
// Strip @provider: prefix if present (e.g. @ollama-cloud:kimi-k2.6)
|
|
2850
2948
|
if (_last.startsWith('@') && _last.includes(':')) _last = _last.split(':').slice(1).join(':');
|
|
2851
2949
|
const looksLikeOllamaTag = /^[a-z0-9][\w.-]*:[\w.-]+$/i.test(_last);
|
|
@@ -3098,9 +3196,10 @@ function renderMd(raw){
|
|
|
3098
3196
|
s=s.replace(/\$\$([\s\S]+?)\$\$/g,(_,m)=>{math_stash.push({type:'display',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
|
|
3099
3197
|
// Match a single literal backslash before the display delimiter (the common LLM form).
|
|
3100
3198
|
s=s.replace(/\\\[([\s\S]+?)\\\]/g,(_,m)=>{math_stash.push({type:'display',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
|
|
3101
|
-
// Inline math: $...$ — require non-space at
|
|
3102
|
-
//
|
|
3103
|
-
|
|
3199
|
+
// Inline math: $...$ — require non-space/non-digit at opening boundary to avoid
|
|
3200
|
+
// false positives on currency like "$1,000 xuống ~$95" or "costs $5 and $10".
|
|
3201
|
+
// Aligns with smd's se() guard which also rejects $ followed by digits.
|
|
3202
|
+
s=s.replace(/\$([^\s$\d\n][^$\n]*?[^\s$\n]|[^\s\d])\$/g,(_,m)=>{if(m.includes(' | '))return '\$'+m+'\$';math_stash.push({type:'inline',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
|
|
3104
3203
|
// Also stash \(...\) LaTeX delimiters.
|
|
3105
3204
|
// Match a single literal backslash before the delimiter (the common LLM form).
|
|
3106
3205
|
s=s.replace(/\\\((.+?)\\\)/g,(_,m)=>{math_stash.push({type:'inline',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
|
|
@@ -3479,6 +3578,19 @@ function renderMd(raw){
|
|
|
3479
3578
|
: `<audio class="msg-media-player msg-media-audio" src="${safeSrc}" controls preload="metadata" title="${safeName}"></audio>`;
|
|
3480
3579
|
return `<div class="msg-media-editor msg-media-editor--${kind}" data-media-kind="${kind}">${tag}<div class="msg-media-meta"><span class="msg-media-name">${safeName}</span></div></div>`;
|
|
3481
3580
|
};
|
|
3581
|
+
const localArtifactCard=(src,name)=>{
|
|
3582
|
+
const safeSrc=esc(src);
|
|
3583
|
+
const safeName=esc(name||'image');
|
|
3584
|
+
const tt=(typeof t==='function')?t:(key=>({media_download:'Download'}[key]||key));
|
|
3585
|
+
// Clean inline image (keeps the existing .msg-media-img lightbox-on-click
|
|
3586
|
+
// behavior) with a hover/focus-revealed Download action overlaid top-right,
|
|
3587
|
+
// matching the ChatGPT/Claude/Gemini pattern. The image stays the hero —
|
|
3588
|
+
// no permanent card chrome. Download is the one affordance the lightbox
|
|
3589
|
+
// (zoom-on-click) doesn't already provide.
|
|
3590
|
+
const dlLabel=esc(tt('media_download'));
|
|
3591
|
+
const dlSvg='<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>';
|
|
3592
|
+
return `<span class="msg-artifact-image"><img class="msg-media-img" src="${safeSrc}" alt="${safeName}" loading="lazy"><a class="msg-artifact-download" href="${safeSrc}" download="${safeName}" title="${dlLabel}" aria-label="${dlLabel}" onclick="event.stopPropagation()">${dlSvg}</a></span>`;
|
|
3593
|
+
};
|
|
3482
3594
|
if(/^file:\/\//i.test(ref)){
|
|
3483
3595
|
try{
|
|
3484
3596
|
const u=new URL(ref);
|
|
@@ -3520,7 +3632,7 @@ function renderMd(raw){
|
|
|
3520
3632
|
const apiUrl='api/media?path='+encodeURIComponent(ref)+(mediaSessionId?'&session_id='+encodeURIComponent(mediaSessionId):'');
|
|
3521
3633
|
const localKind=mediaKindForName(ref);
|
|
3522
3634
|
if(localKind==='image'){
|
|
3523
|
-
return
|
|
3635
|
+
return localArtifactCard(apiUrl,ref.split('/').pop()||'image');
|
|
3524
3636
|
}
|
|
3525
3637
|
// SVG → inline image (no download, render directly)
|
|
3526
3638
|
if(_SVG_EXTS.test(ref)){
|
|
@@ -3564,6 +3676,10 @@ function renderMd(raw){
|
|
|
3564
3676
|
return s;
|
|
3565
3677
|
}
|
|
3566
3678
|
|
|
3679
|
+
function _stripAttachedFilesMarkerForDisplay(text){
|
|
3680
|
+
return String(text||'').replace(/\n\n\[Attached files: [^\]]+\]$/,'').trim();
|
|
3681
|
+
}
|
|
3682
|
+
|
|
3567
3683
|
function setStatus(t){
|
|
3568
3684
|
if(!t)return;
|
|
3569
3685
|
showToast(t, 4000);
|
|
@@ -4311,6 +4427,8 @@ function _stripForTTS(text){
|
|
|
4311
4427
|
text=text.replace(/\[([^\]]+)\]\([^)]+\)/g,'$1');
|
|
4312
4428
|
// Replace MEDIA: paths with a simple label
|
|
4313
4429
|
text=text.replace(/MEDIA:[^\s]+/g,'a file');
|
|
4430
|
+
// Strip emoji and emoticons
|
|
4431
|
+
text=text.replace(/[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F1E0}-\u{1F1FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FE0F}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{200D}]/gu,'');
|
|
4314
4432
|
// Strip HTML tags that may leak through markdown
|
|
4315
4433
|
text=text.replace(/<[^>]+>/g,' ');
|
|
4316
4434
|
// Collapse whitespace
|
|
@@ -4320,18 +4438,13 @@ function _stripForTTS(text){
|
|
|
4320
4438
|
|
|
4321
4439
|
let _ttsSpeaking=false;
|
|
4322
4440
|
let _ttsCurrentUtterance=null;
|
|
4441
|
+
let _playingEdgeAudio=null;
|
|
4323
4442
|
|
|
4324
4443
|
function speakMessage(btn){
|
|
4325
|
-
if(!('speechSynthesis' in window)){
|
|
4326
|
-
showToast(t('tts_not_supported')||'Speech synthesis not supported in this browser.');
|
|
4327
|
-
return;
|
|
4328
|
-
}
|
|
4329
|
-
// If already speaking this message, stop
|
|
4330
4444
|
if(btn&&btn.dataset.speaking==='1'){
|
|
4331
4445
|
stopTTS();
|
|
4332
4446
|
return;
|
|
4333
4447
|
}
|
|
4334
|
-
// Stop any current speech
|
|
4335
4448
|
stopTTS();
|
|
4336
4449
|
|
|
4337
4450
|
const row=btn?btn.closest('[data-raw-text]'):null;
|
|
@@ -4341,6 +4454,17 @@ function speakMessage(btn){
|
|
|
4341
4454
|
const clean=_stripForTTS(text);
|
|
4342
4455
|
if(!clean) return;
|
|
4343
4456
|
|
|
4457
|
+
const engine=localStorage.getItem('hermes-tts-engine')||'browser';
|
|
4458
|
+
if(engine==='edge'){
|
|
4459
|
+
_playEdgeTts(clean, btn);
|
|
4460
|
+
return;
|
|
4461
|
+
}
|
|
4462
|
+
|
|
4463
|
+
if(!('speechSynthesis' in window)){
|
|
4464
|
+
showToast(t('tts_not_supported')||'Speech synthesis not supported in this browser.');
|
|
4465
|
+
return;
|
|
4466
|
+
}
|
|
4467
|
+
|
|
4344
4468
|
const utter=new SpeechSynthesisUtterance(clean);
|
|
4345
4469
|
|
|
4346
4470
|
// Apply saved voice preference
|
|
@@ -4367,10 +4491,59 @@ function speakMessage(btn){
|
|
|
4367
4491
|
speechSynthesis.speak(utter);
|
|
4368
4492
|
}
|
|
4369
4493
|
|
|
4494
|
+
function _playEdgeTts(text, btn){
|
|
4495
|
+
const voice=localStorage.getItem('hermes-tts-voice')||'zh-CN-XiaoxiaoNeural';
|
|
4496
|
+
const savedRate=parseFloat(localStorage.getItem('hermes-tts-rate'));
|
|
4497
|
+
const savedPitch=parseFloat(localStorage.getItem('hermes-tts-pitch'));
|
|
4498
|
+
let rate='', pitch='';
|
|
4499
|
+
if(!isNaN(savedRate)){const pct=Math.round((savedRate-1)*100);const sign=pct>=0?'+':'';rate=sign+pct+'%';}
|
|
4500
|
+
if(!isNaN(savedPitch)){const hz=Math.round((savedPitch-1)*50);const sign=hz>=0?'+':'';pitch=sign+hz+'Hz';}
|
|
4501
|
+
if(btn) btn.dataset.speaking='1';
|
|
4502
|
+
_ttsSpeaking=true;
|
|
4503
|
+
// /api/tts is POST-only (and behind the same-origin CSRF gate); GET via
|
|
4504
|
+
// new Audio(url) would 405 and silently fail, and would also leak the message
|
|
4505
|
+
// text into the query string / access log. POST the JSON body, then play the
|
|
4506
|
+
// returned audio via an object URL — mirrors the working boot.js edge path.
|
|
4507
|
+
const _fail=function(msg){
|
|
4508
|
+
_ttsSpeaking=false;_playingEdgeAudio=null;
|
|
4509
|
+
if(btn)btn.dataset.speaking='0';
|
|
4510
|
+
if(msg&&typeof showToast==='function') showToast(msg,4000,'error');
|
|
4511
|
+
};
|
|
4512
|
+
fetch(new URL('api/tts', document.baseURI || location.href).href, {
|
|
4513
|
+
method:'POST',
|
|
4514
|
+
headers:{'Content-Type':'application/json'},
|
|
4515
|
+
body:JSON.stringify({text:text, voice:voice, rate:rate, pitch:pitch})
|
|
4516
|
+
})
|
|
4517
|
+
.then(function(r){
|
|
4518
|
+
if(!r.ok){
|
|
4519
|
+
// Surface the server error (e.g. 503 "edge-tts not installed", 429 rate limit)
|
|
4520
|
+
return r.json().catch(function(){return {};}).then(function(j){
|
|
4521
|
+
throw new Error((j&&j.error)||('TTS request failed: '+r.status));
|
|
4522
|
+
});
|
|
4523
|
+
}
|
|
4524
|
+
return r.blob();
|
|
4525
|
+
})
|
|
4526
|
+
.then(function(blob){
|
|
4527
|
+
const url=URL.createObjectURL(blob);
|
|
4528
|
+
const audio=new Audio(url);
|
|
4529
|
+
_playingEdgeAudio=audio;
|
|
4530
|
+
const _cleanup=function(){_ttsSpeaking=false;_playingEdgeAudio=null;if(btn)btn.dataset.speaking='0';try{URL.revokeObjectURL(url);}catch(_){}};
|
|
4531
|
+
audio.onended=_cleanup;
|
|
4532
|
+
audio.onerror=function(){_cleanup();};
|
|
4533
|
+
audio.play().catch(function(e){_cleanup();showToast('Edge TTS error: '+(e&&e.message||e));});
|
|
4534
|
+
})
|
|
4535
|
+
.catch(function(e){ _fail((e&&e.message)||'Edge TTS failed'); });
|
|
4536
|
+
}
|
|
4537
|
+
|
|
4370
4538
|
function stopTTS(){
|
|
4371
4539
|
if('speechSynthesis' in window){
|
|
4372
4540
|
speechSynthesis.cancel();
|
|
4373
4541
|
}
|
|
4542
|
+
// Stop Edge TTS audio
|
|
4543
|
+
if(_playingEdgeAudio){
|
|
4544
|
+
try{ _playingEdgeAudio.pause(); _playingEdgeAudio.currentTime=0; }catch(_){}
|
|
4545
|
+
_playingEdgeAudio=null;
|
|
4546
|
+
}
|
|
4374
4547
|
_ttsSpeaking=false;
|
|
4375
4548
|
_ttsCurrentUtterance=null;
|
|
4376
4549
|
// Reset all speaking buttons
|
|
@@ -4378,7 +4551,8 @@ function stopTTS(){
|
|
|
4378
4551
|
}
|
|
4379
4552
|
|
|
4380
4553
|
function autoReadLastAssistant(){
|
|
4381
|
-
|
|
4554
|
+
const engine=localStorage.getItem('hermes-tts-engine')||'browser';
|
|
4555
|
+
if(engine==='browser'&&!('speechSynthesis' in window)) return;
|
|
4382
4556
|
const pref=localStorage.getItem('hermes-tts-auto-read');
|
|
4383
4557
|
if(pref!=='true') return;
|
|
4384
4558
|
// Find the last assistant message segment in the DOM
|
|
@@ -4389,7 +4563,10 @@ function autoReadLastAssistant(){
|
|
|
4389
4563
|
if(!text.trim()) return;
|
|
4390
4564
|
const clean=_stripForTTS(text);
|
|
4391
4565
|
if(!clean) return;
|
|
4392
|
-
|
|
4566
|
+
if(engine==='edge'){
|
|
4567
|
+
_playEdgeTts(clean, null);
|
|
4568
|
+
return;
|
|
4569
|
+
}
|
|
4393
4570
|
const utter=new SpeechSynthesisUtterance(clean);
|
|
4394
4571
|
const savedVoice=localStorage.getItem('hermes-tts-voice');
|
|
4395
4572
|
const voices=speechSynthesis.getVoices();
|
|
@@ -5390,7 +5567,7 @@ function syncTopbar(){
|
|
|
5390
5567
|
// modelSelect already set above
|
|
5391
5568
|
// Update profile chip label
|
|
5392
5569
|
const profileLabel=$('profileChipLabel');
|
|
5393
|
-
if(profileLabel) profileLabel.textContent=S.activeProfile||'default';
|
|
5570
|
+
if(profileLabel) profileLabel.textContent=(S.session&&S.session.profile)||S.activeProfile||'default';
|
|
5394
5571
|
}
|
|
5395
5572
|
|
|
5396
5573
|
function msgContent(m){
|
|
@@ -5400,6 +5577,29 @@ function msgContent(m){
|
|
|
5400
5577
|
return String(c).trim();
|
|
5401
5578
|
}
|
|
5402
5579
|
|
|
5580
|
+
function _isRecoveryControlMessageText(text){
|
|
5581
|
+
const normalized=String(text||'').replace(/\s+/g,' ').trim();
|
|
5582
|
+
if(!normalized) return false;
|
|
5583
|
+
const systemRecovery=/^\[System:/i.test(normalized)
|
|
5584
|
+
&& /previous response was cut off by a network error/i.test(normalized)
|
|
5585
|
+
&& /continue exactly where you left off/i.test(normalized);
|
|
5586
|
+
const backendRecovery=/^the live worker stopped before this run finished\.?$/i.test(normalized);
|
|
5587
|
+
return !!(systemRecovery || backendRecovery);
|
|
5588
|
+
}
|
|
5589
|
+
function _isRecoveryControlMessage(m){
|
|
5590
|
+
if(!m||m.role==='tool') return false;
|
|
5591
|
+
if(m.recovery_control===true) return true;
|
|
5592
|
+
// Backward-compat ONLY: strict fully-anchored text match for pre-marker
|
|
5593
|
+
// persisted sessions. NOT provider_details_label — a real "Response
|
|
5594
|
+
// interrupted" card carries 'Interruption details' and must stay visible.
|
|
5595
|
+
return _isRecoveryControlMessageText(msgContent(m)||String(m.content||''));
|
|
5596
|
+
}
|
|
5597
|
+
function _assistantMessageHasVisibleContent(m){
|
|
5598
|
+
if(!m||m.role!=='assistant') return false;
|
|
5599
|
+
if(_isRecoveryControlMessage(m)) return false;
|
|
5600
|
+
return !!msgContent(m);
|
|
5601
|
+
}
|
|
5602
|
+
|
|
5403
5603
|
function _fmtDateSep(d){
|
|
5404
5604
|
const todayStart=new Date();todayStart.setHours(0,0,0,0);
|
|
5405
5605
|
const dStart=new Date(d);dStart.setHours(0,0,0,0);
|
|
@@ -6106,6 +6306,7 @@ let _sessionHtmlCacheSid=null; // session_id currently rendered in the DOM
|
|
|
6106
6306
|
function clearMessageRenderCache(){
|
|
6107
6307
|
_sessionHtmlCache.clear();
|
|
6108
6308
|
_sessionHtmlCacheSid=null;
|
|
6309
|
+
clearVisibleMessageRowCache();
|
|
6109
6310
|
}
|
|
6110
6311
|
|
|
6111
6312
|
function _messageRenderCacheSignature(){
|
|
@@ -6275,6 +6476,7 @@ function _restoreMessageScrollSnapshot(snapshot){
|
|
|
6275
6476
|
const maxTop=Math.max(0,el.scrollHeight-el.clientHeight);
|
|
6276
6477
|
_programmaticScroll=true;
|
|
6277
6478
|
el.scrollTop=Math.max(0,Math.min(Number(snapshot.top)||0,maxTop));
|
|
6479
|
+
// Sync _lastScrollTop after programmatic restore so sticky-unpin does not false-trigger (#1731).
|
|
6278
6480
|
_lastScrollTop=el.scrollTop;
|
|
6279
6481
|
requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); });
|
|
6280
6482
|
}
|
|
@@ -6352,10 +6554,12 @@ function renderMessages(options){
|
|
|
6352
6554
|
if(!m||!m.role||m.role==='tool')return false;
|
|
6353
6555
|
if(_isContextCompactionMessage(m)) return false;
|
|
6354
6556
|
if(_isPreservedCompressionTaskListMessage(m)) return false;
|
|
6557
|
+
if(_isRecoveryControlMessage(m)) return false;
|
|
6355
6558
|
if(m.role==='assistant'){
|
|
6356
6559
|
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
|
|
6357
6560
|
const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
|
|
6358
6561
|
if(hasTc||hasTu||_messageHasReasoningPayload(m)) return true;
|
|
6562
|
+
if(_assistantMessageHasVisibleContent(m)) return true;
|
|
6359
6563
|
}
|
|
6360
6564
|
return m._statusCard||msgContent(m)||m.attachments?.length;
|
|
6361
6565
|
});
|
|
@@ -6382,9 +6586,10 @@ function renderMessages(options){
|
|
|
6382
6586
|
for(const m of S.messages){
|
|
6383
6587
|
if(!m||!m.role||m.role==='tool'){ri++;continue;}
|
|
6384
6588
|
if(_isPreservedCompressionTaskListMessage(m)){ri++;continue;}
|
|
6589
|
+
if(_isRecoveryControlMessage(m)){ri++;continue;}
|
|
6385
6590
|
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
|
|
6386
6591
|
const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
|
|
6387
|
-
if(msgContent(m)||m._statusCard||m.attachments?.length||(m.role==='assistant'&&(hasTc||hasTu||_messageHasReasoningPayload(m)))) rebuilt.push({m,rawIdx:ri});
|
|
6592
|
+
if(msgContent(m)||m._statusCard||m.attachments?.length||(m.role==='assistant'&&(hasTc||hasTu||_messageHasReasoningPayload(m)||_assistantMessageHasVisibleContent(m)))) rebuilt.push({m,rawIdx:ri});
|
|
6388
6593
|
ri++;
|
|
6389
6594
|
}
|
|
6390
6595
|
_visWithIdxCache=rebuilt;
|
|
@@ -6530,7 +6735,7 @@ function renderMessages(options){
|
|
|
6530
6735
|
if(!isUser&&_isMarkerOnlyAssistantCompressionMessage(m)){
|
|
6531
6736
|
content='**Error:** No response received after context compression. Please retry.';
|
|
6532
6737
|
}
|
|
6533
|
-
const displayContent=isUser?_stripWorkspaceDisplayPrefix(content):content;
|
|
6738
|
+
const displayContent=isUser?_stripAttachedFilesMarkerForDisplay(_stripWorkspaceDisplayPrefix(content)):content;
|
|
6534
6739
|
if(thinkingText&&!isUser){
|
|
6535
6740
|
thinkingText=_stripVisibleAssistantEchoFromThinking(thinkingText, displayContent);
|
|
6536
6741
|
}
|
|
@@ -7116,14 +7321,53 @@ function buildToolCard(tc){
|
|
|
7116
7321
|
Object.entries(tc.args).map(([k,v])=>`<div><span class="tool-arg-key">${esc(k)}</span> <span class="tool-arg-val">${esc(String(v))}</span></div>`).join('')
|
|
7117
7322
|
}</div>`:''}
|
|
7118
7323
|
${displaySnippet?`<div class="tool-card-result">
|
|
7119
|
-
<pre>${esc(displaySnippet)}</pre>
|
|
7120
|
-
${hasMore?`<button class="tool-card-more" data-full="${esc(tc.snippet||'').replace(/"/g,'"')}" data-short="${esc(displaySnippet||'').replace(/"/g,'"')}" data-more-label="${esc(moreLabel)}" data-less-label="${esc(lessLabel)}" onclick="event.stopPropagation();
|
|
7324
|
+
<pre>${tc.is_diff||_snippetLooksLikeDiff(displaySnippet)?`<code class="diff-block" data-highlighted="1">${_colorDiffLines(displaySnippet)}</code>`:esc(displaySnippet)}</pre>
|
|
7325
|
+
${hasMore?`<button class="tool-card-more" data-full="${esc(tc.snippet||'').replace(/"/g,'"')}" data-short="${esc(displaySnippet||'').replace(/"/g,'"')}" data-is-diff="${tc.is_diff||_snippetLooksLikeDiff(displaySnippet)?1:0}" data-more-label="${esc(moreLabel)}" data-less-label="${esc(lessLabel)}" onclick="event.stopPropagation();_toggleToolDiff(this)">${esc(moreLabel)}</button>`:''}
|
|
7121
7326
|
</div>`:''}
|
|
7122
7327
|
</div>`:''}
|
|
7123
7328
|
</div>`;
|
|
7124
7329
|
return row;
|
|
7125
7330
|
}
|
|
7126
7331
|
|
|
7332
|
+
function _colorDiffLines(text){
|
|
7333
|
+
if(typeof text !== 'string') return esc(String(text||''));
|
|
7334
|
+
return esc(text).split('\n').map(line=>{
|
|
7335
|
+
if(line.startsWith('@@')) return `<span class="diff-line diff-hunk">${line}</span>`;
|
|
7336
|
+
if(line.startsWith('+')&&!line.startsWith('+++')) return `<span class="diff-line diff-plus">${line}</span>`;
|
|
7337
|
+
if(line.startsWith('-')&&!line.startsWith('---')) return `<span class="diff-line diff-minus">${line}</span>`;
|
|
7338
|
+
return `<span class="diff-line">${line}</span>`;
|
|
7339
|
+
}).join('\n');
|
|
7340
|
+
}
|
|
7341
|
+
|
|
7342
|
+
// Detect if text looks like a unified diff (has @@ hunk headers and +/- lines).
|
|
7343
|
+
function _snippetLooksLikeDiff(text){
|
|
7344
|
+
if(typeof text!=='string'||text.length<10) return false;
|
|
7345
|
+
if(!/^@@\s/.test(text)) return false;
|
|
7346
|
+
const lines=text.split('\n');
|
|
7347
|
+
let plusMinus=0;
|
|
7348
|
+
for(let i=0;i<lines.length&&i<50;i++){
|
|
7349
|
+
const l=lines[i];
|
|
7350
|
+
if(l.startsWith('+')||l.startsWith('-')) plusMinus++;
|
|
7351
|
+
}
|
|
7352
|
+
return plusMinus>=2;
|
|
7353
|
+
}
|
|
7354
|
+
|
|
7355
|
+
function _toggleToolDiff(btn){
|
|
7356
|
+
const pre=btn.closest('.tool-card-result')?.querySelector('pre');
|
|
7357
|
+
if(!pre) return;
|
|
7358
|
+
const isDiff=btn.dataset.isDiff==='1';
|
|
7359
|
+
const expanded=btn.textContent===btn.dataset.moreLabel;
|
|
7360
|
+
const raw=expanded?btn.dataset.full:btn.dataset.short;
|
|
7361
|
+
if(isDiff){
|
|
7362
|
+
let code=pre.querySelector('code');
|
|
7363
|
+
if(!code){code=document.createElement('code');code.className='diff-block';pre.textContent='';pre.appendChild(code);}
|
|
7364
|
+
code.innerHTML=_colorDiffLines(raw);
|
|
7365
|
+
}else{
|
|
7366
|
+
pre.textContent=raw;
|
|
7367
|
+
}
|
|
7368
|
+
btn.textContent=expanded?btn.dataset.lessLabel:btn.dataset.moreLabel;
|
|
7369
|
+
}
|
|
7370
|
+
|
|
7127
7371
|
function _syncToolCallGroupSummary(group){
|
|
7128
7372
|
if(!group) return;
|
|
7129
7373
|
const cards=Array.from(group.querySelectorAll('.tool-card-row .tool-card'));
|