dsh-remote-plugin 0.7.2 → 0.7.3

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.
@@ -0,0 +1,160 @@
1
+ /* Shared bounded conversation rendering and asynchronous local cache. No dependencies. */
2
+ 'use strict';
3
+ ;(function(root) {
4
+ const WINDOW_SIZE = 180, WINDOW_STEP = 60, fingerprints = new WeakMap()
5
+ function signature(entry) {
6
+ if (!fingerprints.has(entry)) fingerprints.set(entry, JSON.stringify([entry.seq, entry.event, entry.view]))
7
+ return fingerprints.get(entry)
8
+ }
9
+ function reconcile(previous, incoming, floor = -Infinity, retainOlder = true, ceiling = Infinity) {
10
+ const old = new Map(previous.map(entry => [entry.seq, entry]))
11
+ const next = new Map(previous.filter(entry => entry.seq >= ceiling || (retainOlder && entry.seq < floor)).map(entry => [entry.seq, entry]))
12
+ for (const entry of incoming) {
13
+ const existing = old.get(entry.seq)
14
+ next.set(entry.seq, existing && signature(existing) === signature(entry) ? existing : entry)
15
+ }
16
+ return [...next.values()].sort((a,b) => a.seq-b.seq)
17
+ }
18
+ class HistoryView {
19
+ constructor(box, options) {
20
+ this.box=box; this.options=options; this.entries=[]; this.start=0; this.end=0; this.nodes=new Map(); this.reading=false; this.suppress=false; this.frame=0; this.pendingOlder=false
21
+ this.more=document.createElement('button'); this.more.type='button'; this.more.className='history-page-control'; this.more.textContent=options.olderLabel()
22
+ this.more.addEventListener('click',()=>this.older())
23
+ this.empty=document.createElement('div'); this.empty.className='history-empty'
24
+ this.live=document.createElement('div'); this.live.className='history-live'
25
+ const intent=()=>{this.intentUntil=Date.now()+800}
26
+ for(const name of ['wheel','touchmove','pointerdown'])box.addEventListener(name,intent,{passive:true})
27
+ box.addEventListener('scroll',()=>{
28
+ if(this.suppress || this.frame)return
29
+ this.frame=requestAnimationFrame(()=>{this.frame=0;this.onScroll()})
30
+ },{passive:true})
31
+ }
32
+ anchor() {
33
+ const top=this.box.getBoundingClientRect().top
34
+ for(const node of this.box.querySelectorAll('.history-entry'))if(node.getBoundingClientRect().bottom>=top+1)return {seq:Number(node.dataset.historySeq),top:node.getBoundingClientRect().top}
35
+ return null
36
+ }
37
+ set(entries, {reset=false, follow=false, live='', hasMore=false, loading=false, emptyLabel=''}={}) {
38
+ if(!reset&&entries.length===this.entries.length&&entries.every((entry,i)=>entry===this.entries[i])&&hasMore===this.hasMore&&loading===this.loading&&(this.end===entries.length?live:'')===this.liveHtml){this.entries=entries;this.more.disabled=loading;this.more.textContent=loading?this.options.loadingLabel():this.options.olderLabel();this.domChanged=false;this.options.changed?.(this);return}
39
+ if(Date.now()<this.intentUntil)this.reading=this.box.scrollHeight-this.box.scrollTop-this.box.clientHeight>=180
40
+ const oldWidth=this.end-this.start
41
+ const anchor=this.anchor(), oldStart=this.entries[this.start]?.seq, oldEnd=this.entries[Math.max(this.start,this.end-1)]?.seq
42
+ this.entries=entries; this.hasMore=hasMore; this.loading=loading
43
+ if(reset || !this.end){this.end=entries.length;this.start=Math.max(0,this.end-WINDOW_SIZE);this.reading=false}
44
+ else if((follow || !this.reading) && this.end>=this.previousLength){this.end=entries.length;this.start=Math.max(0,this.end-WINDOW_SIZE)}
45
+ else {
46
+ let start=entries.findIndex(e=>e.seq===oldStart),end=entries.findIndex(e=>e.seq===oldEnd)
47
+ if(start<0)start=anchor?entries.findIndex(e=>e.seq===anchor.seq):0
48
+ this.start=Math.max(0,start-(this.pendingOlder?WINDOW_STEP:0));this.end=Math.min(entries.length,Math.max(this.start+1,end+1,this.start+Math.min(WINDOW_SIZE,oldWidth)))
49
+ }
50
+ this.previousLength=entries.length
51
+ this.empty.textContent=emptyLabel
52
+ this.more.textContent=loading?this.options.loadingLabel():this.options.olderLabel();this.more.disabled=loading;this.more.hidden=this.start===0&&!hasMore
53
+ this.liveHtml=this.end===entries.length?live:''
54
+ this.render(anchor,reset || (!this.reading && this.end===entries.length))
55
+ }
56
+ render(anchor, bottom=false) {
57
+ const box=this.box, slice=this.entries.slice(this.start,Math.min(this.end,this.start+WINDOW_SIZE)), desired=[]
58
+ this.end=this.start+slice.length
59
+ const rangeKey=this.start+':'+this.end
60
+ let changed=rangeKey!==this.lastRange || this.live.innerHTML!==this.liveHtml
61
+ this.lastRange=rangeKey
62
+ for(const entry of slice) {
63
+ const key=signature(entry);let cached=this.nodes.get(entry.seq)
64
+ if(!cached || cached.key!==key){changed=true;const node=document.createElement('div');node.className='history-entry';node.dataset.historySeq=String(entry.seq);node.innerHTML=this.options.html(entry);cached={key,node};this.nodes.set(entry.seq,cached)}
65
+ desired.push(cached.node)
66
+ }
67
+ this.more.hidden=this.start===0&&!this.hasMore
68
+ const keep=new Set(desired)
69
+ this.suppress=true
70
+ for(const child of [...box.children])if(child!==this.more && child!==this.live && child!==this.empty && !keep.has(child))child.remove()
71
+ if(this.more.parentNode!==box)box.prepend(this.more)
72
+ let cursor=this.more.nextSibling
73
+ for(const node of desired){if(node===cursor)cursor=cursor.nextSibling;else box.insertBefore(node,cursor)}
74
+ if(!slice.length && !this.liveHtml){if(this.empty.parentNode!==box)box.append(this.empty)}else this.empty.remove()
75
+ if(this.live.innerHTML!==this.liveHtml)this.live.innerHTML=this.liveHtml
76
+ if(this.live.parentNode!==box)box.append(this.live)
77
+ if(bottom)box.scrollTop=box.scrollHeight
78
+ else if(anchor){const current=this.nodes.get(anchor.seq)?.node;if(current?.parentNode===box)box.scrollTop+=current.getBoundingClientRect().top-anchor.top}
79
+ const active=new Set(slice.map(entry=>entry.seq));for(const seq of this.nodes.keys())if(!active.has(seq))this.nodes.delete(seq)
80
+ cancelAnimationFrame(this.releaseFrame);this.releaseFrame=requestAnimationFrame(()=>{this.suppress=false})
81
+ this.domChanged=changed
82
+ this.options.changed?.(this)
83
+ }
84
+ onScroll() {
85
+ const box=this.box
86
+ if(Date.now()<this.intentUntil)this.reading=box.scrollHeight-box.scrollTop-box.clientHeight>=180
87
+ if(box.scrollTop<80 && Date.now()<this.intentUntil)this.older()
88
+ else if(box.scrollHeight-box.scrollTop-box.clientHeight<120 && this.end<this.entries.length && Date.now()<this.intentUntil){const anchor=this.anchor();this.end=Math.min(this.entries.length,this.end+WINDOW_STEP);this.start=Math.max(0,this.end-WINDOW_SIZE);this.render(anchor)}
89
+ this.options.scrolled?.(this)
90
+ this.domChanged=false
91
+ this.options.changed?.(this)
92
+ }
93
+ async older() {
94
+ if(this.pendingOlder)return
95
+ if(this.start>0){const anchor=this.anchor();this.start=Math.max(0,this.start-WINDOW_STEP);this.end=Math.min(this.entries.length,this.start+WINDOW_SIZE);this.reading=true;this.render(anchor);return}
96
+ if(!this.hasMore || this.loading)return
97
+ this.reading=true
98
+ this.pendingOlder=true
99
+ try{await this.options.older()}finally{this.pendingOlder=false}
100
+ }
101
+ latest() {this.reading=false;this.end=this.entries.length;this.start=Math.max(0,this.end-WINDOW_SIZE);this.liveHtml=this.options.live?.()||'';this.render(null,true)}
102
+ reset() {cancelAnimationFrame(this.frame);cancelAnimationFrame(this.releaseFrame);this.frame=0;this.entries=[];this.start=this.end=this.previousLength=0;this.lastRange='';this.nodes.clear();this.reading=false;this.pendingOlder=false;this.suppress=false;this.intentUntil=0;this.box.replaceChildren()}
103
+ }
104
+ let cacheEpoch=0
105
+ let database
106
+ function openDatabase() {
107
+ if(database)return database
108
+ database=new Promise(resolve=>{
109
+ if(typeof indexedDB==='undefined'){resolve(null);return}
110
+ const request=indexedDB.open('dshRemoteHistoryV1',1),timer=setTimeout(()=>resolve(null),1500)
111
+ request.onupgradeneeded=()=>{const store=request.result.createObjectStore('history',{keyPath:'key'});store.createIndex('scopeTime',['scope','updatedAt'])}
112
+ request.onsuccess=()=>{clearTimeout(timer);request.result.onversionchange=()=>request.result.close();resolve(request.result)}
113
+ request.onerror=request.onblocked=()=>{clearTimeout(timer);resolve(null)}
114
+ })
115
+ return database
116
+ }
117
+ function decodeLegacy(raw,id) {
118
+ if(!raw)return Promise.resolve(null)
119
+ if(raw.length>250000&&typeof Worker!=='undefined')return new Promise(resolve=>{
120
+ let worker,url,timer
121
+ const done=value=>{clearTimeout(timer);worker?.terminate();if(url)URL.revokeObjectURL(url);resolve(value)}
122
+ try{url=URL.createObjectURL(new Blob(["self.onmessage=e=>{try{self.postMessage(JSON.parse(e.data.raw)[e.data.id]||null)}catch{self.postMessage(null)}}"],{type:'text/javascript'}));worker=new Worker(url);worker.onmessage=e=>done(e.data);worker.onerror=()=>done(null);timer=setTimeout(()=>done(null),3000);worker.postMessage({raw,id})}catch{done(null)}
123
+ })
124
+ try{return Promise.resolve(JSON.parse(raw)[id]||null)}catch{return Promise.resolve(null)}
125
+ }
126
+ const cacheKey=(scope,id)=>scope+':session:'+encodeURIComponent(id)
127
+ async function cacheLoad(scope,id,legacy) {
128
+ const key=cacheKey(scope,id),db=await openDatabase()
129
+ if(db){const value=await new Promise(resolve=>{try{const request=db.transaction('history').objectStore('history').get(key);request.onsuccess=()=>resolve(request.result);request.onerror=()=>resolve(null)}catch{resolve(null)}});if(value)return value}
130
+ try{const value=JSON.parse(localStorage.getItem(key)||'null');if(value)return value}catch{}
131
+ // One-time compatibility with the old per-connection JSON cache.
132
+ try{const value=await legacy?.(id);if(value){void cacheSave(scope,id,value);return value}return null}catch{return null}
133
+ }
134
+ async function cacheSave(scope,id,value) {
135
+ const epoch=cacheEpoch,key=cacheKey(scope,id),kept=[];let size=0
136
+ for(let i=(value.events||[]).length-1;i>=0;i--){const entry=value.events[i],length=signature(entry).length;if(size+length>750000)break;kept.unshift(entry);size+=length}
137
+ if(!kept.length)return false
138
+ const data={...value,events:kept,minSeq:kept[0].seq,hasMore:value.hasMore||kept.length<(value.events||[]).length,key,scope,id,updatedAt:Date.now()},db=await openDatabase()
139
+ if(epoch!==cacheEpoch)return false
140
+ if(db)try{const saved=await new Promise(resolve=>{
141
+ const tx=db.transaction('history','readwrite'),store=tx.objectStore('history');store.put(data)
142
+ let count=0;const request=store.index('scopeTime').openKeyCursor(IDBKeyRange.bound([scope,0],[scope,Number.MAX_SAFE_INTEGER]),'prev')
143
+ request.onsuccess=()=>{const cursor=request.result;if(!cursor)return;if(++count>10)store.delete(cursor.primaryKey);cursor.continue()}
144
+ tx.oncomplete=()=>resolve(true);tx.onabort=tx.onerror=()=>resolve(false)
145
+ });if(saved)return true}catch{}
146
+ if(epoch!==cacheEpoch)return false
147
+ try{
148
+ const indexKey=scope+':cache-index',index=JSON.parse(localStorage.getItem(indexKey)||'[]').filter(item=>item!==key),serialized=JSON.stringify(data)
149
+ while(true){try{localStorage.setItem(key,serialized);break}catch(error){if(!index.length)throw error;localStorage.removeItem(index.pop())}}
150
+ index.unshift(key)
151
+ for(const removed of index.splice(10))localStorage.removeItem(removed)
152
+ localStorage.setItem(indexKey,JSON.stringify(index));return kept.length>0
153
+ }catch{return false}
154
+ }
155
+ async function cacheRemove(scope,id) {const key=cacheKey(scope,id);try{localStorage.removeItem(key)}catch{};const db=await openDatabase();if(db)try{db.transaction('history','readwrite').objectStore('history').delete(key)}catch{}}
156
+ async function cacheClear() {cacheEpoch++;try{for(const key of Object.keys(localStorage))if(key.includes(':session:')||key.endsWith(':cache-index')||/^(history|sessions)Cache/.test(key))localStorage.removeItem(key)}catch{};const db=await openDatabase();if(db)await new Promise(resolve=>{try{const tx=db.transaction('history','readwrite');tx.objectStore('history').clear();tx.oncomplete=tx.onabort=tx.onerror=()=>resolve()}catch{resolve()}})}
157
+ const api={HistoryView,reconcile,signature,cacheLoad,cacheSave,cacheRemove,cacheClear,decodeLegacy,WINDOW_SIZE}
158
+ if(typeof module!=='undefined'&&module.exports)module.exports=api
159
+ else root.DshHistory=api
160
+ })(typeof window==='undefined'?globalThis:window)
package/public/index.html CHANGED
@@ -108,17 +108,15 @@
108
108
  <div id="session-head" class="session-head">
109
109
  <button id="btn-back" class="icon-btn" data-i18n-title="settings.back" data-i18n-aria="settings.back">‹</button>
110
110
  <div class="session-head-meta">
111
- <div id="session-title" class="session-title" data-i18n="session.loading">加载中…</div>
112
- <div id="session-sub" class="session-sub"></div>
111
+ <button id="session-title" class="session-title" type="button" aria-haspopup="dialog" aria-controls="session-info" data-i18n="session.loading">加载中…</button>
112
+
113
113
  </div>
114
- <button id="btn-rename-session" class="icon-btn hidden" data-i18n-title="session.rename" data-i18n-aria="session.rename">✎</button>
115
- <button id="btn-archive-session" class="mini-btn hidden" data-i18n="session.archive">归档</button>
116
- <button id="btn-stats" class="icon-btn" data-i18n-title="stats.title" data-i18n-aria="stats.title"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 19V9M12 19V5M19 19v-7M3 19h18"/></svg></button>
117
- <button id="btn-insights" class="icon-btn" data-i18n-title="insights.title" data-i18n-aria="insights.title"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="12" cy="12" r="8"/><path d="M12 4v8l6 5M12 12H4"/></svg></button>
114
+ <button id="btn-session-more" class="icon-btn" type="button" aria-haspopup="dialog" aria-controls="session-actions" data-i18n-aria="session.more">⋯</button>
118
115
  <button id="btn-fs-send" class="session-send-btn hidden" type="button">发送</button>
119
- <button id="btn-cancel" class="danger-btn hidden" data-i18n="session.stop">停止</button>
116
+
120
117
  </div>
121
118
 
119
+ <button id="session-sub" class="session-sub" type="button" aria-haspopup="dialog" aria-controls="session-info"></button>
122
120
  <div id="session-cards" class="cards"></div>
123
121
 
124
122
  <div id="queue-dock" class="queue-dock hidden" aria-live="polite"></div>
@@ -127,10 +125,11 @@
127
125
 
128
126
  <div class="section-head small">
129
127
  <span data-i18n="session.history">对话</span>
130
- <span id="history-hint" class="muted"></span>
128
+ <span class="history-meta"><span id="history-source" class="history-source" role="status" aria-live="polite"></span><span id="history-hint" class="muted"></span></span>
131
129
  </div>
132
130
  <div id="history-wrap" class="history-wrap">
133
131
  <div id="history" class="history"></div>
132
+ <button id="btn-history-latest" class="history-latest hidden" type="button" data-i18n="history.latest">回到最新</button>
134
133
  <div class="history-rail">
135
134
  <div class="rail-track"></div>
136
135
  <div id="rail-thumb" class="rail-thumb"></div>
@@ -139,7 +138,7 @@
139
138
  </div>
140
139
 
141
140
  <div id="composer-wrap" class="composer-wrap">
142
- <div id="composer-status" class="composer-status hidden" role="status" aria-live="polite"><span class="composer-status-dot" aria-hidden="true"></span><span id="composer-status-text" data-i18n="composer.running">运行中…</span></div>
141
+ <div id="composer-status" class="composer-status hidden" role="status" aria-live="polite"><span class="composer-status-dot" aria-hidden="true"></span><span id="composer-status-text" data-i18n="composer.running">运行中…</span><button id="btn-cancel" class="danger-btn hidden" type="button" data-i18n="session.stop">停止本轮</button></div>
143
142
  <div id="composer-menu" class="composer-menu hidden">
144
143
  <div class="menu-group">
145
144
  <div class="menu-title" data-i18n="menu.commandsTitle">输入指令</div>
@@ -207,12 +206,12 @@
207
206
  <div class="overview-pulse-card">
208
207
  <div class="overview-pulse-copy">
209
208
  <div class="overview-eyebrow" data-i18n="overview.systemPulse">系统链路</div>
210
- <div id="overview-status" class="overview-status" data-i18n="overview.checking">检查中…</div>
209
+ <div id="overview-status" aria-live="polite" class="overview-status" data-i18n="overview.checking">检查中…</div>
211
210
  <div id="overview-status-desc" class="overview-status-desc">—</div>
212
211
  <button id="overview-primary-action" class="overview-primary-action" type="button" data-i18n="overview.action.openSession">打开最近会话</button>
213
212
  </div>
214
- <div id="overview-pulse-ring" class="system-pulse-ring" style="--pulse-pct:0%" aria-label="System health">
215
- <div class="system-pulse-core"><strong id="overview-health">—</strong><span id="overview-health-caption">—</span></div>
213
+ <div id="overview-pulse-ring" class="system-pulse-ring" aria-label="System health">
214
+ <svg class="pulse-dial" viewBox="0 0 120 120" aria-hidden="true" focusable="false"><path data-pulse-link="gateway" data-confirmed="false" d="M 66.093 10.373 A 50 50 0 0 1 109.627 53.907"/><path data-pulse-link="dsh" data-confirmed="false" d="M 109.627 66.093 A 50 50 0 0 1 66.093 109.627"/><path data-pulse-link="mux" data-confirmed="false" d="M 53.907 109.627 A 50 50 0 0 1 10.373 66.093"/><path data-pulse-link="host" data-confirmed="false" d="M 10.373 53.907 A 50 50 0 0 1 53.907 10.373"/></svg><span class="pulse-sweep" aria-hidden="true"></span><span class="pulse-reticle" aria-hidden="true"></span><div class="system-pulse-core"><strong id="overview-health">—</strong><span id="overview-health-caption">—</span></div>
216
215
  </div>
217
216
  <div id="overview-links" class="overview-links" aria-label="System components">
218
217
  <div class="overview-link" data-overview-link="gateway"><i></i><span data-i18n="overview.gateway">网关</span><b>—</b></div>
@@ -258,7 +257,6 @@
258
257
  <!-- 设置 -->
259
258
  <section id="view-settings" class="view hidden">
260
259
  <div id="settings-home">
261
- <button type="button" id="btn-plugin-center" class="setting-row setting-link"><span data-i18n="nav.plugins">插件</span></button>
262
260
  <div class="settings-group">
263
261
  <button type="button" class="setting-row setting-link" data-settings-group="general">
264
262
  <div><div class="setting-name" data-i18n="settings.groupGeneral">通用</div><div class="setting-desc" data-i18n="settings.groupGeneralDesc">显示工具调用、预设提示词</div></div>
@@ -520,6 +518,22 @@
520
518
  <button id="btn-check-update" class="mini-btn" data-i18n="settings.check">检查</button>
521
519
  </div>
522
520
  </div>
521
+ <div class="settings-group">
522
+ <section class="setting-row contributors-section" aria-labelledby="contributors-title">
523
+ <div>
524
+ <div id="contributors-title" class="setting-name" data-i18n="settings.contributors">贡献者</div>
525
+ <p class="contributors-thanks" data-i18n="settings.contributorsThanks">感谢每一位让 DSH Remote 变得更好的贡献者。</p>
526
+ <!-- GitHub contributions 降序;同数量按用户名排序。更新名单时核对仓库 contributors API。 -->
527
+ <ul class="contributors-list">
528
+ <li><a href="https://github.com/Blank-not-black" target="_blank" rel="noopener noreferrer">Blank-not-black</a></li>
529
+ <li><a href="https://github.com/wikkd" target="_blank" rel="noopener noreferrer">wikkd</a></li>
530
+ <li><a href="https://github.com/anupamme" target="_blank" rel="noopener noreferrer">anupamme</a></li>
531
+ <li><a href="https://github.com/liuchang-t" target="_blank" rel="noopener noreferrer">liuchang-t</a></li>
532
+ </ul>
533
+ <a class="contributors-all" href="https://github.com/Blank-not-black/dsh-Remote/graphs/contributors" target="_blank" rel="noopener noreferrer" data-i18n="settings.contributorsAll">查看完整贡献记录 ↗</a>
534
+ </div>
535
+ </section>
536
+ </div>
523
537
  </div>
524
538
  </section>
525
539
  </main>
@@ -629,6 +643,8 @@
629
643
  <textarea id="fb-msg" class="fb-textarea" rows="5" maxlength="2000" data-i18n-placeholder="feedback.messagePlaceholder" placeholder="请描述遇到的问题或建议(必填,≤2000 字)"></textarea>
630
644
  <input id="fb-contact" class="fb-input" maxlength="200" data-i18n-placeholder="feedback.contactPlaceholder" placeholder="联系方式(可选):邮箱 / 微信 / B站 ID">
631
645
  <label class="fb-diagnostics"><input id="fb-include-diagnostics" type="checkbox"> <span data-i18n="feedback.includeDiagnostics">附带兼容性诊断日志(可选)</span><small data-i18n="feedback.diagnosticsPrivacy">仅上传网关/协议版本、接口失败摘要和实时链路状态;不包含令牌、Cookie、对话内容或文件路径。</small></label>
646
+ <label class="fb-diagnostics"><input id="fb-include-crashes" type="checkbox"> <span data-i18n="feedback.includeCrashes">附带崩溃日志摘要(可选)</span><small data-i18n="feedback.crashPrivacy">默认仅在本机记录。勾选后上传最近 3 条退出、重启和错误位置摘要,不含对话、密钥或完整路径。</small></label>
647
+ <details id="fb-crash-details" class="crash-preview"><summary data-i18n="feedback.crashPreview">查看拟上传的日志摘要</summary><pre id="fb-crash-preview"></pre></details>
632
648
  </div>
633
649
  <div class="modal-actions">
634
650
  <button id="fb-cancel" class="btn subtle" data-i18n="feedback.cancel">取消</button>
@@ -728,6 +744,21 @@
728
744
  </div>
729
745
  </div>
730
746
 
747
+
748
+ <dialog id="session-actions" class="session-actions-dialog" aria-labelledby="session-actions-title">
749
+ <div class="session-dialog-head"><strong id="session-actions-title" data-i18n="session.more">更多操作</strong><button type="button" data-session-dialog-close aria-label="关闭">×</button></div>
750
+ <button id="btn-rename-session" class="icon-btn hidden" data-i18n-title="session.rename" data-i18n-aria="session.rename" data-i18n="session.rename">重命名会话</button>
751
+ <button id="btn-stats" class="icon-btn" data-i18n-title="stats.title" data-i18n-aria="stats.title" data-i18n="stats.title">本轮统计</button>
752
+ <button id="btn-insights" class="icon-btn" data-i18n-title="insights.title" data-i18n-aria="insights.title" data-i18n="insights.title">上下文与费用</button>
753
+
754
+ <button id="btn-session-info" type="button" data-i18n="session.details">会话详情</button>
755
+ <button id="btn-archive-session" class="mini-btn hidden" data-i18n="session.archive">归档</button>
756
+ </dialog>
757
+ <dialog id="session-info" class="session-info-dialog" aria-labelledby="session-info-title">
758
+ <div class="session-dialog-head"><strong id="session-info-title" data-i18n="session.details">会话详情</strong><button type="button" data-session-dialog-close aria-label="关闭">×</button></div>
759
+ <dl><dt data-i18n="session.fullTitle">完整标题</dt><dd id="session-info-name"></dd><dt data-i18n="session.workspace">工作区</dt><dd id="session-info-path"></dd><dt data-i18n="session.id">会话 ID</dt><dd id="session-info-id"></dd></dl>
760
+ <button id="btn-copy-session-workspace" type="button" data-i18n="session.copyWorkspace">复制工作区路径</button>
761
+ </dialog>
731
762
  <div id="modal-rename" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="rename-session-title">
732
763
  <div class="modal-card">
733
764
  <div id="rename-session-title" class="modal-title" data-i18n="session.renameTitle">重命名会话</div>
@@ -901,7 +932,7 @@
901
932
  'common.refreshing': '刷新中…', 'select.choose': '请选择', 'select.cancel': '取消',
902
933
  'nav.sessions': '会话', 'nav.files': '文件', 'nav.pending': '待办', 'nav.overview': '总览', 'nav.home': '主页', 'nav.plugins': '插件', 'nav.settings': '设置',
903
934
  'overview.eyebrow': 'SYSTEM OVERVIEW', 'overview.title': '主页', 'overview.subtitle': '实时状态与需要你处理的事项',
904
- 'overview.systemPulse': '系统链路', 'overview.nominal': '系统链路正常', 'overview.degraded': '部分链路需要关注', 'overview.offline': '连接已断开', 'overview.checking': '检查中…', 'overview.components': '{n}/4 个模块在线', 'overview.live': 'LIVE', 'overview.allLinked': '全部链路', 'overview.offlineCore': 'OFFLINE',
935
+ 'overview.systemPulse': '系统链路', 'overview.nominal': '系统链路正常', 'overview.degraded': '部分链路需要关注', 'overview.offline': '连接已断开', 'overview.checking': '检查中…', 'overview.confirmedShort': '已确认', 'overview.checkingShort': '检测中', 'overview.autoChecking': '正在自动检测,已确认 {n}/4 项', 'overview.autoRetry': '{n}/4 项在线,将自动复查未连接的链路', 'overview.components': '{n}/4 个模块在线', 'overview.live': 'LIVE', 'overview.allLinked': '全部链路', 'overview.offlineCore': 'OFFLINE',
905
936
  'overview.gateway': '网关', 'overview.dsh': 'DSH 上游', 'overview.mux': '实时 mux', 'overview.host': '实时 host', 'overview.online': '在线', 'overview.offlineShort': '离线',
906
937
  'overview.attention': '需要你处理', 'overview.metrics': '运行指标', 'overview.activity': '近期活动', 'overview.dshVersion': 'DSH 版本', 'overview.gatewayVersion': '网关状态',
907
938
  'overview.activeSessions': '活跃会话', 'overview.connectionMode': '连接模式', 'overview.ws': '实时 WS', 'overview.poll': '降级轮询', 'overview.noActivity': '暂无后台活动', 'overview.noSession': '暂无会话',
@@ -948,12 +979,12 @@
948
979
  'groups.drawerOpen': '切换服务器组', 'groups.drawerClose': '关闭组列表', 'groups.drawerEyebrow': '快速切换', 'groups.drawerTitle': '切换服务器组', 'groups.drawerCurrent': '当前连接组',
949
980
  'groups.drawerManage': '管理服务器与组', 'groups.drawerManageDesc': '编辑、测速和调整组', 'groups.serverCount': '{count} 台服务器',
950
981
  'stream.error': '事件流错误:{msg}',
951
- 'session.loading': '加载中…', 'session.unknown': '未知会话', 'session.untitled': '新会话', 'session.running': '运行中', 'session.interrupted': '已中断',
982
+ 'session.more': '更多操作', 'session.details': '会话详情', 'session.workspace': '工作区', 'session.noWorkspace': '未关联工作区', 'session.copyWorkspace': '复制工作区路径', 'session.id': '会话 ID', 'session.fullTitle': '完整标题', 'session.loading': '加载中…', 'session.unknown': '未知会话', 'session.untitled': '新会话', 'session.running': '运行中', 'session.interrupted': '已中断',
952
983
  'session.stop': '停止本轮', 'session.history': '对话', 'session.errorMsg': '会话出错:{msg}', 'session.recovering': '恢复会话中…', 'session.recoveryReady': '会话已恢复', 'session.recoveryFailed': '会话恢复失败', 'session.recoveryCached': '正在查看缓存历史',
953
984
  'session.confirmStop': '停止当前回合?排队中的消息不会被删除。', 'session.stopFailed': '停止失败', 'session.stopRequested': '已请求停止本轮', 'session.archive': '归档', 'session.archiveConfirm': '归档这个会话?归档后可在“显示已归档会话”中打开。', 'session.archiveFailed': '归档失败:{msg}', 'session.archived': '会话已归档', 'session.archiveTitle': '确认归档会话', 'session.archiveDesc': '归档后会话将从普通列表隐藏,但不会删除对话记录。', 'session.archiveConversation': '对话', 'session.archiveWorkspace': '工作区', 'session.archiveCancel': '取消', 'session.archiveConfirmAction': '确认归档', 'session.rename': '重命名会话', 'session.renameTitle': '重命名会话', 'session.renamePlaceholder': '输入新的会话名称', 'session.renameCancel': '取消', 'session.renameConfirm': '保存', 'session.renameEmpty': '会话名称不能为空', 'session.renameFailed': '重命名失败', 'session.renamed': '会话名称已更新',
954
985
  'notify.approvalTitle': '工具审批', 'notify.approvalBody': '{tool} 需要批准', 'notify.questionTitle': 'DSH 提问', 'notify.questionBody': '需要你回答',
955
986
  'notify.permissionFailed': '通知权限申请失败:{msg}',
956
- 'history.loading': '加载历史…', 'history.cacheFallback': '网络不可用:显示本地缓存的历史', 'history.loadFailed': '加载历史失败:{msg}', 'history.retry': '重试',
987
+ 'history.cached': '本地缓存', 'history.syncing': '本地缓存 · 正在同步', 'history.synced': '最新记录已同步', 'history.saved': '最新记录 · 已缓存', 'history.offline': '本地记录 · 网络不可用', 'history.earlier': '加载更早的记录', 'history.loadingEarlier': '正在加载更早的记录…', 'history.latest': '回到最新', 'history.cacheFailed': '已同步 · 本地缓存未保存', 'history.loading': '加载历史…', 'history.cacheFallback': '网络不可用:显示本地缓存的历史', 'history.loadFailed': '加载历史失败:{msg}', 'history.retry': '重试',
957
988
  'history.offlineCache': '离线缓存 {n} 条', 'history.count': '{n} 条', 'history.empty': '还没有消息',
958
989
  'history.queueAndCount': '队列 {q} · 历史 {n}', 'history.countOnly': '历史 {n}',
959
990
  'role.me': '我', 'role.dsh': 'DSH',
@@ -1020,6 +1051,7 @@
1020
1051
  'update.phase.downloading': '正在下载…', 'update.phase.verifying': '正在校验安装包…', 'update.phase.complete': '下载完成', 'update.phase.legacy': '系统正在下载安装包;安装新版后可显示完整进度', 'update.busy': '已有更新下载正在进行',
1021
1052
  'update.downloadStarted': '开始下载,完成后会弹出安装页', 'update.downloadFailed': '无法启动下载:{msg}',
1022
1053
  'update.corrupted': '下载文件损坏,请重试', 'update.serverFileMissing': '服务器上还没有对应版本的文件,请稍后再试',
1054
+ 'update.hashMissing': '更新包缺少 SHA-256 校验值,已取消下载',
1023
1055
  'update.installUnsupported': '当前版本不支持 App 内安装,已转浏览器下载',
1024
1056
  'update.expand': '展开', 'update.collapse': '收起',
1025
1057
  'update.gatewayAheadTitle': 'App 版本需要更新', 'update.gatewayAheadBody': '网关已经更新,但当前 App 仍是较旧版本,部分功能可能缺失或显示异常。',
@@ -1032,6 +1064,7 @@
1032
1064
  'scan.promptHeader': '扫描 DSH Remote 配对二维码', 'scan.promptLive': '实时扫码', 'scan.promptPhoto': '拍照扫描', 'scan.promptGallery': '从相册选择',
1033
1065
  'scan.noPhoto': '没有拿到图片,请重试', 'scan.noQr': '未识别到二维码,请对准后重试',
1034
1066
  'scan.paired': '配对成功,正在连接', 'scan.pairedLink': '已通过二维码配对', 'scan.notPair': '这不是 DSH Remote 的配对二维码',
1067
+ 'scan.pairConfirm': '已配置过服务器,是否用以下地址替换当前连接?\n{servers}',
1035
1068
  'scan.cancelled': '已取消扫码', 'scan.failed': '扫码失败:{msg}',
1036
1069
  'settings.groupGeneral': '通用', 'settings.groupGeneralDesc': '显示工具调用、预设提示词',
1037
1070
  'settings.groupModel': '模型', 'settings.groupModelDesc': '提供方、API 密钥和模型目录',
@@ -1039,7 +1072,7 @@
1039
1072
  'settings.groupServers': '服务器', 'settings.groupServersDesc': '服务器地址、令牌与扫码连接',
1040
1073
  'settings.groupNotify': '通知', 'settings.groupNotifyDesc': '通知、提醒、后台轮询',
1041
1074
  'settings.groupTheme': '皮肤', 'settings.groupThemeDesc': '配色主题',
1042
- 'settings.groupAbout': '关于', 'settings.groupAboutDesc': 'DSH 状态、检查更新',
1075
+ 'settings.groupAbout': '关于', 'settings.groupAboutDesc': 'DSH 状态、检查更新、贡献者致谢',
1043
1076
  'settings.donate': '赞赏支持', 'settings.donateDesc': '如果 dsh-remote 帮到了你,欢迎赞赏支持开发 ☕', 'settings.donateThanks': '感谢你的支持 ☕',
1044
1077
  'settings.back': '‹ 返回',
1045
1078
  'settings.modelTitle': '模型', 'settings.modelIntro': '填入各提供方的 API 密钥即可使用其模型。', 'settings.modelRefresh': '刷新', 'settings.modelOpenConfig': '打开配置文件',
@@ -1096,7 +1129,7 @@
1096
1129
  'feedback.title': '反馈', 'feedback.githubDesc': '反馈 bug / 提建议', 'feedback.giteeDesc': '国内镜像,无需代理',
1097
1130
  'feedback.biliDesc': 'UP 动态页交流', 'feedback.copyLink': '复制项目链接', 'feedback.copyDesc': '手动分享给朋友',
1098
1131
  'feedback.copied': '项目链接已复制', 'feedback.copyFailed': '复制失败,请手动复制',
1099
- 'feedback.write': '写反馈', 'feedback.writeDesc': 'App 内直接提交', 'feedback.modalTitle': '写反馈', 'feedback.includeDiagnostics': '附带兼容性诊断日志(可选)', 'feedback.diagnosticsPrivacy': '仅上传网关/协议版本、接口失败摘要和实时链路状态;不包含令牌、Cookie、对话内容或文件路径。',
1132
+ 'feedback.write': '写反馈', 'feedback.writeDesc': 'App 内直接提交', 'feedback.modalTitle': '写反馈', 'feedback.includeDiagnostics': '附带兼容性诊断日志(可选)', 'feedback.diagnosticsPrivacy': '仅上传网关/协议版本、接口失败摘要和实时链路状态;不包含令牌、Cookie、对话内容或文件路径。', 'feedback.includeCrashes': '附带崩溃日志摘要(可选)', 'feedback.crashPrivacy': '默认仅在本机记录。勾选后上传最近 3 条退出、重启和错误位置摘要,不含对话、密钥或完整路径。', 'feedback.crashPreview': '查看拟上传的日志摘要', 'feedback.crashLoading': '读取本机日志…', 'feedback.crashUnavailable': '当前网关暂时无法读取日志,请更新网关或稍后重试。',
1100
1133
  'feedback.typeBug': 'Bug', 'feedback.typeSuggestion': '建议', 'feedback.typeOther': '其他',
1101
1134
  'feedback.messagePlaceholder': '请描述遇到的问题或建议(必填,≤2000 字)',
1102
1135
  'feedback.contactPlaceholder': '联系方式(可选):邮箱 / 微信 / B站 ID',
@@ -1108,6 +1141,7 @@
1108
1141
  'settings.resetTitle': '清空本地数据', 'settings.resetDesc': '令牌与缓存', 'settings.reset': '重置',
1109
1142
  'settings.confirmReset': '清除本地令牌、服务器与缓存?', 'settings.notifyDenied': '通知权限未开启',
1110
1143
  'settings.toolsShown': '已显示工具调用', 'settings.toolsHidden': '已隐藏工具调用',
1144
+ 'settings.contributors': '贡献者', 'settings.contributorsThanks': '感谢每一位让 DSH Remote 变得更好的贡献者。', 'settings.contributorsAll': '查看完整贡献记录 ↗',
1111
1145
  'settings.about': 'DSH Remote · 局域网 / Tailscale 均可访问<br>仅作为 DSH 本机 3080 端口的带认证代理',
1112
1146
  'modal.approvalTitle': '工具审批', 'modal.reject': '拒绝', 'modal.allowOnce': '允许一次',
1113
1147
  'modal.questionTitle': 'DSH 需要你回答', 'modal.later': '稍后', 'modal.submit': '提交',
@@ -1137,7 +1171,7 @@
1137
1171
  'common.refreshing': 'Refreshing…', 'select.choose': 'Choose an option', 'select.cancel': 'Cancel',
1138
1172
  'nav.sessions': 'Sessions', 'nav.files': 'Files', 'nav.pending': 'Inbox', 'nav.overview': 'Overview', 'nav.home': 'Home', 'nav.plugins': 'Plugins', 'nav.settings': 'Settings',
1139
1173
  'overview.eyebrow': 'SYSTEM OVERVIEW', 'overview.title': 'Home', 'overview.subtitle': 'Live status and items needing your attention',
1140
- 'overview.systemPulse': 'System pulse', 'overview.nominal': 'System nominal', 'overview.degraded': 'Some links need attention', 'overview.offline': 'Connection offline', 'overview.checking': 'Checking…', 'overview.components': '{n}/4 components online', 'overview.live': 'LIVE', 'overview.allLinked': 'ALL LINKED', 'overview.offlineCore': 'OFFLINE',
1174
+ 'overview.systemPulse': 'System pulse', 'overview.nominal': 'System nominal', 'overview.degraded': 'Some links need attention', 'overview.offline': 'Connection offline', 'overview.checking': 'Checking…', 'overview.confirmedShort': 'CONFIRMED', 'overview.checkingShort': 'Checking', 'overview.autoChecking': 'Checking automatically · {n}/4 confirmed', 'overview.autoRetry': '{n}/4 online · Automatic retry scheduled', 'overview.components': '{n}/4 components online', 'overview.live': 'LIVE', 'overview.allLinked': 'ALL LINKED', 'overview.offlineCore': 'OFFLINE',
1141
1175
  'overview.gateway': 'Gateway', 'overview.dsh': 'DSH upstream', 'overview.mux': 'Live mux', 'overview.host': 'Live host', 'overview.online': 'Online', 'overview.offlineShort': 'Offline',
1142
1176
  'overview.attention': 'Needs attention', 'overview.metrics': 'Runtime metrics', 'overview.activity': 'Recent activity', 'overview.dshVersion': 'DSH version', 'overview.gatewayVersion': 'Gateway status',
1143
1177
  'overview.activeSessions': 'Active sessions', 'overview.connectionMode': 'Connection mode', 'overview.ws': 'Live WS', 'overview.poll': 'Degraded polling', 'overview.noActivity': 'No background activity', 'overview.noSession': 'No sessions yet',
@@ -1184,12 +1218,12 @@
1184
1218
  'groups.drawerOpen': 'Switch server group', 'groups.drawerClose': 'Close group list', 'groups.drawerEyebrow': 'QUICK SWITCH', 'groups.drawerTitle': 'Switch server group', 'groups.drawerCurrent': 'Current connection group',
1185
1219
  'groups.drawerManage': 'Manage servers and groups', 'groups.drawerManageDesc': 'Edit, test, and organize groups', 'groups.serverCount': '{count} servers',
1186
1220
  'stream.error': 'Event stream error: {msg}',
1187
- 'session.loading': 'Loading…', 'session.unknown': 'Unknown session', 'session.untitled': 'New session', 'session.running': 'Running', 'session.interrupted': 'Interrupted',
1221
+ 'session.more': 'More actions', 'session.details': 'Session details', 'session.workspace': 'Workspace', 'session.noWorkspace': 'No workspace', 'session.copyWorkspace': 'Copy workspace path', 'session.id': 'Session ID', 'session.fullTitle': 'Full title', 'session.loading': 'Loading…', 'session.unknown': 'Unknown session', 'session.untitled': 'New session', 'session.running': 'Running', 'session.interrupted': 'Interrupted',
1188
1222
  'session.stop': 'Stop turn', 'session.history': 'Conversation', 'session.errorMsg': 'Session error: {msg}', 'session.recovering': 'Restoring session…', 'session.recoveryReady': 'Session ready', 'session.recoveryFailed': 'Session restore failed', 'session.recoveryCached': 'Viewing cached history',
1189
1223
  'session.confirmStop': 'Stop the current turn? Queued messages will be kept.', 'session.stopFailed': 'Stop failed', 'session.stopRequested': 'Stop requested', 'session.archive': 'Archive', 'session.archiveConfirm': 'Archive this session? You can open it from “Show archived”.', 'session.archiveFailed': 'Archive failed: {msg}', 'session.archived': 'Session archived', 'session.archiveTitle': 'Confirm archive', 'session.archiveDesc': 'The session will be hidden from the normal list, but its conversation will not be deleted.', 'session.archiveConversation': 'Conversation', 'session.archiveWorkspace': 'Workspace', 'session.archiveCancel': 'Cancel', 'session.archiveConfirmAction': 'Archive session', 'session.rename': 'Rename session', 'session.renameTitle': 'Rename session', 'session.renamePlaceholder': 'Enter a new session name', 'session.renameCancel': 'Cancel', 'session.renameConfirm': 'Save', 'session.renameEmpty': 'Session name cannot be empty', 'session.renameFailed': 'Rename failed', 'session.renamed': 'Session name updated',
1190
1224
  'notify.approvalTitle': 'Tool approval', 'notify.approvalBody': '{tool} needs approval', 'notify.questionTitle': 'DSH question', 'notify.questionBody': 'Needs your answer',
1191
1225
  'notify.permissionFailed': 'Notification permission failed: {msg}',
1192
- 'history.loading': 'Loading history…', 'history.cacheFallback': 'Network unavailable: showing cached history', 'history.loadFailed': 'Failed to load history: {msg}', 'history.retry': 'Retry',
1226
+ 'history.cached': 'Local cache', 'history.syncing': 'Local cache · Syncing', 'history.synced': 'Latest history synced', 'history.saved': 'Latest history · Cached', 'history.offline': 'Local history · Offline', 'history.earlier': 'Load earlier history', 'history.loadingEarlier': 'Loading earlier history…', 'history.latest': 'Jump to latest', 'history.cacheFailed': 'Synced · Cache could not be saved', 'history.loading': 'Loading history…', 'history.cacheFallback': 'Network unavailable: showing cached history', 'history.loadFailed': 'Failed to load history: {msg}', 'history.retry': 'Retry',
1193
1227
  'history.offlineCache': 'Offline cache {n} items', 'history.count': '{n} items', 'history.empty': 'No messages yet',
1194
1228
  'history.queueAndCount': 'Queue {q} · History {n}', 'history.countOnly': 'History {n}',
1195
1229
  'role.me': 'Me', 'role.dsh': 'DSH',
@@ -1256,6 +1290,7 @@
1256
1290
  'update.phase.downloading': 'Downloading…', 'update.phase.verifying': 'Verifying APK…', 'update.phase.complete': 'Download complete', 'update.phase.legacy': 'System download started; install this update for full progress', 'update.busy': 'An update is already downloading',
1257
1291
  'update.downloadStarted': 'Downloading, the install page will open when ready', 'update.downloadFailed': 'Could not start download: {msg}',
1258
1292
  'update.corrupted': 'Downloaded file is corrupted, please retry', 'update.serverFileMissing': 'The file for this version is not on the server yet, please try again later',
1293
+ 'update.hashMissing': 'The update package has no SHA-256 checksum; download cancelled',
1259
1294
  'update.installUnsupported': 'This version cannot install in-app, opening browser download',
1260
1295
  'update.expand': 'Expand', 'update.collapse': 'Collapse',
1261
1296
  'update.gatewayAheadTitle': 'App update recommended', 'update.gatewayAheadBody': 'The gateway is newer than this App. Some features may be missing or display incorrectly.',
@@ -1268,6 +1303,7 @@
1268
1303
  'scan.promptHeader': 'Scan DSH Remote pairing QR', 'scan.promptLive': 'Live scan', 'scan.promptPhoto': 'Take photo', 'scan.promptGallery': 'Choose from gallery',
1269
1304
  'scan.noPhoto': 'No image received, try again', 'scan.noQr': 'No QR code detected, aim and try again',
1270
1305
  'scan.paired': 'Paired, connecting', 'scan.pairedLink': 'Paired via QR code', 'scan.notPair': 'Not a DSH Remote pairing QR code',
1306
+ 'scan.pairConfirm': 'Servers are already configured. Replace the current connection with these addresses?\n{servers}',
1271
1307
  'scan.cancelled': 'Scan cancelled', 'scan.failed': 'Scan failed: {msg}',
1272
1308
  'settings.groupGeneral': 'General', 'settings.groupGeneralDesc': 'Tool calls, prompt presets',
1273
1309
  'settings.groupModel': 'Models', 'settings.groupModelDesc': 'Providers, API keys, and model catalogs',
@@ -1332,7 +1368,7 @@
1332
1368
  'feedback.title': 'Feedback', 'feedback.githubDesc': 'Report bugs · suggest features', 'feedback.giteeDesc': 'Mirror in China, no proxy needed',
1333
1369
  'feedback.biliDesc': 'Chat on the UP\'s Bilibili page', 'feedback.copyLink': 'Copy project link', 'feedback.copyDesc': 'Share it manually',
1334
1370
  'feedback.copied': 'Project link copied', 'feedback.copyFailed': 'Copy failed, copy manually',
1335
- 'feedback.write': 'Write feedback', 'feedback.writeDesc': 'Submit from the app', 'feedback.modalTitle': 'Write feedback', 'feedback.includeDiagnostics': 'Include compatibility diagnostics (optional)', 'feedback.diagnosticsPrivacy': 'Uploads only gateway/protocol versions, interface failure summaries, and realtime status; no token, cookie, conversation content, or file path.',
1371
+ 'feedback.write': 'Write feedback', 'feedback.writeDesc': 'Submit from the app', 'feedback.modalTitle': 'Write feedback', 'feedback.includeDiagnostics': 'Include compatibility diagnostics (optional)', 'feedback.diagnosticsPrivacy': 'Uploads only gateway/protocol versions, interface failure summaries, and realtime status; no token, cookie, conversation content, or file path.', 'feedback.includeCrashes': 'Include crash summary (optional)', 'feedback.crashPrivacy': 'Recorded locally by default. Opt in to upload up to 3 recent exit, restart and error-location records. No conversations, credentials or full paths.', 'feedback.crashPreview': 'Preview the log summary to upload', 'feedback.crashLoading': 'Reading local logs…', 'feedback.crashUnavailable': 'Logs are unavailable. Update the gateway or try again later.',
1336
1372
  'feedback.typeBug': 'Bug', 'feedback.typeSuggestion': 'Suggestion', 'feedback.typeOther': 'Other',
1337
1373
  'feedback.messagePlaceholder': 'Describe the bug or suggestion (required, ≤2000 chars)',
1338
1374
  'feedback.contactPlaceholder': 'Contact (optional): email / WeChat / Bilibili ID',
@@ -1344,6 +1380,7 @@
1344
1380
  'settings.resetTitle': 'Clear local data', 'settings.resetDesc': 'Token and caches', 'settings.reset': 'Reset',
1345
1381
  'settings.confirmReset': 'Clear local token, servers and caches?', 'settings.notifyDenied': 'Notification permission not granted',
1346
1382
  'settings.toolsShown': 'Tool calls shown', 'settings.toolsHidden': 'Tool calls hidden',
1383
+ 'settings.contributors': 'Contributors', 'settings.contributorsThanks': 'Thank you to everyone who helps make DSH Remote better.', 'settings.contributorsAll': 'View all contributions ↗',
1347
1384
  'settings.about': 'DSH Remote · accessible over LAN / Tailscale<br>Authenticated proxy in front of the local DSH 3080 endpoint',
1348
1385
  'modal.approvalTitle': 'Tool approval', 'modal.reject': 'Reject', 'modal.allowOnce': 'Allow once',
1349
1386
  'modal.questionTitle': 'DSH needs your answer', 'modal.later': 'Later', 'modal.submit': 'Submit',
@@ -1366,6 +1403,8 @@
1366
1403
  <script type="module" src="morphicons-init.js"></script>
1367
1404
  <script src="plugin-center.js"></script>
1368
1405
  <script src="insights.js"></script>
1406
+ <script src="history.js"></script>
1407
+ <script src="link-check.js"></script>
1369
1408
  <script src="app.js"></script>
1370
1409
  </body>
1371
1410
  </html>
@@ -0,0 +1,109 @@
1
+ /* Active overview checks. Reuses the existing WS reconnect policy; no extra heartbeat frames. */
2
+ 'use strict';
3
+ ;(function(root){
4
+ class LinkCheck {
5
+ constructor(options){
6
+ this.options=options;this.timer=null;this.graceTimer=null;this.epoch=0;this.job=null;this.abort=null;this.attempt=0
7
+ this.model={phase:'idle',gateway:null,probing:false,checks:{gateway:false,dsh:false,mux:false,host:false}}
8
+ this.now=options.now||Date.now;this.setTimer=options.setTimer||((fn,delay)=>setTimeout(fn,delay));this.clearTimer=options.clearTimer||(id=>clearTimeout(id))
9
+ }
10
+ emit(){this.options.changed?.(this.model)}
11
+ clearTimers(){this.clearTimer(this.timer);this.clearTimer(this.graceTimer);this.timer=this.graceTimer=null}
12
+ pause(){this.epoch++;this.clearTimers();this.abort?.abort();this.abort=null;this.job=null;this.model.probing=false;this.model.phase='idle'}
13
+ start(force=false){
14
+ if(!this.options.active()){this.pause();return}
15
+ if(!force&&this.model.phase!=='idle'){this.observe();return}
16
+ if(force&&this.model.probing&&this.options.configured()&&this.options.online())return
17
+ this.pause();this.attempt=0;this.deadline=this.now()+20000;this.model.gateway=null
18
+ this.model.phase=!this.options.configured()?'unconfigured':!this.options.online()?'offline':'checking'
19
+ this.model.checks=this.options.snapshot();this.emit()
20
+ if(this.model.phase!=='checking')return
21
+ const epoch=this.epoch
22
+ this.graceTimer=this.setTimer(()=>{if(epoch!==this.epoch)return;this.graceTimer=null;if(this.model.phase==='checking'){this.model.phase='degraded';this.emit()}},20000)
23
+ void this.run()
24
+ }
25
+ observe(){
26
+ if(!this.options.active())return
27
+ const checks=this.options.snapshot(),key=JSON.stringify(checks),previous=JSON.stringify(this.model.checks)
28
+ this.model.checks=checks
29
+ if(this.options.configured()&&this.options.online()&&Object.values(checks).every(Boolean)){
30
+ if(this.model.phase!=='ready'){this.model.phase='ready';this.clearTimer(this.graceTimer);this.graceTimer=null;if(!this.model.probing)this.schedule(30000);this.emit()}
31
+ else if(key!==previous)this.emit()
32
+ }else if(this.model.phase==='ready'){
33
+ this.start(true)
34
+ }else if(key!==previous)this.emit()
35
+ }
36
+ schedule(delay){this.clearTimer(this.timer);const epoch=this.epoch;this.timer=this.setTimer(()=>{this.timer=null;if(epoch===this.epoch)void this.run()},delay)}
37
+ async run(){
38
+ if(!this.options.active()){this.pause();return}
39
+ if(!this.options.configured()||!this.options.online()){this.start(true);return}
40
+ if(this.job)return this.job
41
+ const epoch=this.epoch,abort=new AbortController();this.abort=abort;this.model.probing=true;this.emit()
42
+ const job=Promise.resolve().then(async()=>{
43
+ try{
44
+ this.options.repair()
45
+ const result=await this.options.probe(abort.signal)
46
+ if(epoch!==this.epoch)return
47
+ this.model.gateway=result.gateway===true
48
+ }catch{if(epoch===this.epoch)this.model.gateway=false}
49
+ if(epoch!==this.epoch)return
50
+ this.model.checks=this.options.snapshot()
51
+ const complete=Object.values(this.model.checks).every(Boolean)
52
+ this.model.phase=complete?'ready':this.now()<this.deadline?'checking':'degraded'
53
+ this.model.probing=false;this.emit()
54
+ if(complete){this.clearTimer(this.graceTimer);this.graceTimer=null}
55
+ if(this.options.active())this.schedule(complete||this.model.phase==='degraded'?30000:[2000,5000,10000][Math.min(this.attempt++,2)])
56
+ }).finally(()=>{if(epoch===this.epoch&&this.job===job){this.job=null;this.abort=null}})
57
+ this.job=job;return job
58
+ }
59
+ }
60
+ /* Visual feedback never delays or changes network state. CSS owns the continuous sweep. */
61
+ class PulseVisual {
62
+ constructor(options){
63
+ this.card=options.card;this.active=options.active||(()=>!root.document?.hidden)
64
+ this.reduced=options.reduced||(()=>!!root.matchMedia?.('(prefers-reduced-motion: reduce)').matches)
65
+ this.setTimer=options.setTimer||((fn,delay)=>setTimeout(fn,delay));this.clearTimer=options.clearTimer||(id=>clearTimeout(id))
66
+ this.checking=false;this.settleTimer=null;this.textJobs=new Map()
67
+ root.matchMedia?.('(prefers-reduced-motion: reduce)').addEventListener?.('change',event=>{if(event.matches)this.pause();else this.setChecking(this.card.classList.contains('status-checking'))})
68
+ }
69
+ setChecking(checking){
70
+ if(!this.active()||this.reduced()){this.pause();return}
71
+ if(checking===this.checking)return
72
+ this.checking=checking;this.clearTimer(this.settleTimer);this.settleTimer=null
73
+ if(checking)this.card.dataset.pulseRunning='true'
74
+ else this.settleTimer=this.setTimer(()=>{this.settleTimer=null;this.card.dataset.pulseRunning='false'},240)
75
+ }
76
+ setLinks(checks){
77
+ for(const segment of this.card.querySelectorAll('[data-pulse-link]'))segment.setAttribute('data-confirmed',String(checks[segment.getAttribute('data-pulse-link')]===true))
78
+ }
79
+ clearText(node){
80
+ const previous=this.textJobs.get(node);if(!previous)return
81
+ this.textJobs.delete(node);previous.enter?.cancel();previous.exit?.cancel();previous.echo?.remove()
82
+ }
83
+ text(node,value){
84
+ if(!node||node.textContent===value)return
85
+ const interrupted=this.textJobs.has(node),previous=node.textContent,style=root.getComputedStyle?.(node),opacity=style?.opacity||'1',transform=style?.transform||'none'
86
+ this.clearText(node)
87
+ const animate=!!previous&&previous!=='—'&&this.active()&&!this.reduced()&&typeof node.animate==='function'
88
+ let echo
89
+ if(animate){
90
+ echo=node.cloneNode(false);echo.removeAttribute('id');echo.removeAttribute('aria-live');echo.removeAttribute('data-i18n');echo.setAttribute('aria-hidden','true');echo.classList.add('pulse-text-echo');echo.textContent=previous
91
+ Object.assign(echo.style,{left:node.offsetLeft+'px',top:node.offsetTop+'px',width:node.offsetWidth+'px',margin:'0'})
92
+ node.parentElement.appendChild(echo)
93
+ }
94
+ node.textContent=value
95
+ if(!animate)return
96
+ const timing={duration:240,easing:'cubic-bezier(0.23, 1, 0.32, 1)'},job={echo}
97
+ job.enter=node.animate([{opacity:interrupted?opacity:0,transform:transform==='none'?'translateY(3px)':transform},{opacity:1,transform:'translateY(0)'}],timing)
98
+ job.exit=echo.animate([{opacity,transform},{opacity:0,transform:'translateY(-3px)'}],timing)
99
+ this.textJobs.set(node,job)
100
+ Promise.all([job.enter.finished,job.exit.finished]).catch(()=>{}).finally(()=>{if(this.textJobs.get(node)===job){this.textJobs.delete(node);echo.remove()}})
101
+ }
102
+ pause(){
103
+ this.clearTimer(this.settleTimer);this.settleTimer=null;this.checking=false;this.card.dataset.pulseRunning='false'
104
+ for(const node of [...this.textJobs.keys()])this.clearText(node)
105
+ }
106
+ }
107
+ if(typeof module!=='undefined'&&module.exports)module.exports={LinkCheck,PulseVisual}
108
+ else root.DshLinkCheck={LinkCheck,PulseVisual}
109
+ })(typeof window==='undefined'?globalThis:window)
@@ -87,3 +87,14 @@
87
87
  .pc-dialog .pc-toggle::before{content:'';position:absolute;inset:-9px -3px}
88
88
  }
89
89
  @media(prefers-reduced-motion:reduce){.pc-dialog button,.pc-toggle::after{transition:none}}
90
+
91
+ .pc-inline-detail { margin-top:14px; padding-top:14px; border-top:1px solid var(--dsr-line); overflow-wrap:anywhere; }
92
+ .pc-inline-detail:empty,.pc-inline-detail[hidden] { display:none; }
93
+ .pc-inline-detail p,.pc-inline-detail h4 { margin:10px 0 0; font-size:13px; }
94
+ .pc-inline-detail button,.pc-inline-detail input,.pc-inline-detail a { margin:12px 8px 0 0; }
95
+ .pc-inline-detail input { max-width:100%; box-sizing:border-box; }
96
+ .pc-detail-error { color:var(--dsr-warning); }
97
+ .pc-detail-loading { color:var(--dsr-muted); }
98
+
99
+ .pc-list { align-items:start; }
100
+ .pc-inline-detail button,.pc-card-actions button { min-height:44px; }