dsh-remote-plugin 0.7.1 → 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
@@ -14,6 +14,7 @@
14
14
  <link rel="stylesheet" href="styles.css">
15
15
  <link rel="stylesheet" href="genui.css">
16
16
  <link rel="stylesheet" href="plugin-center.css">
17
+ <link rel="stylesheet" href="insights.css">
17
18
  </head>
18
19
  <body>
19
20
  <header class="topbar">
@@ -107,16 +108,15 @@
107
108
  <div id="session-head" class="session-head">
108
109
  <button id="btn-back" class="icon-btn" data-i18n-title="settings.back" data-i18n-aria="settings.back">‹</button>
109
110
  <div class="session-head-meta">
110
- <div id="session-title" class="session-title" data-i18n="session.loading">加载中…</div>
111
- <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
+
112
113
  </div>
113
- <button id="btn-rename-session" class="icon-btn hidden" data-i18n-title="session.rename" data-i18n-aria="session.rename">✎</button>
114
- <button id="btn-archive-session" class="mini-btn hidden" data-i18n="session.archive">归档</button>
115
- <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>
114
+ <button id="btn-session-more" class="icon-btn" type="button" aria-haspopup="dialog" aria-controls="session-actions" data-i18n-aria="session.more">⋯</button>
116
115
  <button id="btn-fs-send" class="session-send-btn hidden" type="button">发送</button>
117
- <button id="btn-cancel" class="danger-btn hidden" data-i18n="session.stop">停止</button>
116
+
118
117
  </div>
119
118
 
119
+ <button id="session-sub" class="session-sub" type="button" aria-haspopup="dialog" aria-controls="session-info"></button>
120
120
  <div id="session-cards" class="cards"></div>
121
121
 
122
122
  <div id="queue-dock" class="queue-dock hidden" aria-live="polite"></div>
@@ -125,10 +125,11 @@
125
125
 
126
126
  <div class="section-head small">
127
127
  <span data-i18n="session.history">对话</span>
128
- <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>
129
129
  </div>
130
130
  <div id="history-wrap" class="history-wrap">
131
131
  <div id="history" class="history"></div>
132
+ <button id="btn-history-latest" class="history-latest hidden" type="button" data-i18n="history.latest">回到最新</button>
132
133
  <div class="history-rail">
133
134
  <div class="rail-track"></div>
134
135
  <div id="rail-thumb" class="rail-thumb"></div>
@@ -137,7 +138,7 @@
137
138
  </div>
138
139
 
139
140
  <div id="composer-wrap" class="composer-wrap">
140
- <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>
141
142
  <div id="composer-menu" class="composer-menu hidden">
142
143
  <div class="menu-group">
143
144
  <div class="menu-title" data-i18n="menu.commandsTitle">输入指令</div>
@@ -205,12 +206,12 @@
205
206
  <div class="overview-pulse-card">
206
207
  <div class="overview-pulse-copy">
207
208
  <div class="overview-eyebrow" data-i18n="overview.systemPulse">系统链路</div>
208
- <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>
209
210
  <div id="overview-status-desc" class="overview-status-desc">—</div>
210
211
  <button id="overview-primary-action" class="overview-primary-action" type="button" data-i18n="overview.action.openSession">打开最近会话</button>
211
212
  </div>
212
- <div id="overview-pulse-ring" class="system-pulse-ring" style="--pulse-pct:0%" aria-label="System health">
213
- <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>
214
215
  </div>
215
216
  <div id="overview-links" class="overview-links" aria-label="System components">
216
217
  <div class="overview-link" data-overview-link="gateway"><i></i><span data-i18n="overview.gateway">网关</span><b>—</b></div>
@@ -248,54 +249,14 @@
248
249
  <div class="overview-legacy hidden" aria-hidden="true"><div id="pending-list"></div><div id="jobs-list"></div><span id="pending-count"></span></div>
249
250
  </section>
250
251
 
251
- <!-- Token 统计 -->
252
- <section id="view-stats" class="view hidden stats-view" aria-labelledby="stats-page-title">
253
- <header class="stats-page-head">
254
- <div class="stats-page-copy">
255
- <div class="stats-eyebrow" data-i18n="statsPage.eyebrow">USAGE OVERVIEW</div>
256
- <h1 id="stats-page-title" data-i18n="statsPage.title">Token 统计</h1>
257
- <p class="stats-page-subtitle" data-i18n="statsPage.subtitle">按日查看 Token 用量与费用估算</p>
258
- </div>
259
- <div class="stats-date-wrap">
260
- <span class="stats-date-label" data-i18n="statsPage.latest">最新日期</span>
261
- <span id="stats-sub" class="stats-date">—</span>
262
- </div>
263
- </header>
264
- <div class="stats-toolbar">
265
- <span class="stats-toolbar-label" data-i18n="statsPage.chartMetric">图表指标</span>
266
- <div id="stats-mode-picker" class="stats-mode-picker" role="group" data-i18n-aria="statsPage.chartMetric">
267
- <button type="button" class="stats-mode-btn active" data-stats-mode="token" aria-pressed="true" data-i18n="statsPage.modeTokens">Token 数</button>
268
- <button type="button" class="stats-mode-btn" data-stats-mode="cost" aria-pressed="false" data-i18n="statsPage.modeCost">费用(官方)</button>
269
- </div>
270
- </div>
271
- <div class="stats-cards" id="stats-cards"></div>
272
- <section class="stats-panel" data-stats-panel="cost" aria-labelledby="stats-cost-title">
273
- <div class="stats-panel-head">
274
- <div>
275
- <div class="stats-panel-kicker" data-i18n="statsPage.costKicker">ESTIMATED COST</div>
276
- <h2 id="stats-cost-title" class="stats-panel-title" data-i18n="statsPage.costTrend">费用趋势</h2>
277
- </div>
278
- <div id="stats-legend" class="stats-legend"></div>
279
- </div>
280
- <div id="stats-chart" class="stats-chart" role="list" aria-live="polite" data-i18n-aria="statsPage.costChartLabel"></div>
281
- </section>
282
- <section class="stats-panel" data-stats-panel="token" aria-labelledby="stats-token-title">
283
- <div class="stats-panel-head">
284
- <div>
285
- <div class="stats-panel-kicker" data-i18n="statsPage.tokenKicker">TOKEN VOLUME</div>
286
- <h2 id="stats-token-title" class="stats-panel-title" data-i18n="statsPage.tokenTrend">Token 量趋势</h2>
287
- </div>
288
- <div id="stats-token-legend" class="stats-legend"></div>
289
- </div>
290
- <div id="stats-token-chart" class="stats-chart" role="list" aria-live="polite" data-i18n-aria="statsPage.tokenChartLabel"></div>
291
- </section>
292
- <p id="stats-note" class="stats-note"></p>
252
+ <!-- 插件管理:独立页面,与当前连接绑定 -->
253
+ <section id="view-plugins" class="view hidden plugins-view" aria-label="插件管理">
254
+ <div id="plugin-page"></div>
293
255
  </section>
294
256
 
295
257
  <!-- 设置 -->
296
258
  <section id="view-settings" class="view hidden">
297
259
  <div id="settings-home">
298
- <button type="button" id="btn-plugin-center" class="setting-row setting-link"><span>插件中心 · 已安装 / 发现插件</span></button>
299
260
  <div class="settings-group">
300
261
  <button type="button" class="setting-row setting-link" data-settings-group="general">
301
262
  <div><div class="setting-name" data-i18n="settings.groupGeneral">通用</div><div class="setting-desc" data-i18n="settings.groupGeneralDesc">显示工具调用、预设提示词</div></div>
@@ -557,6 +518,22 @@
557
518
  <button id="btn-check-update" class="mini-btn" data-i18n="settings.check">检查</button>
558
519
  </div>
559
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>
560
537
  </div>
561
538
  </section>
562
539
  </main>
@@ -619,9 +596,9 @@
619
596
  <span class="nav-home-orb"><span class="nav-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="7.5"/><circle cx="12" cy="12" r="2.2" fill="currentColor" stroke="none"/><path d="M12 2.5v3M12 18.5v3M2.5 12h3M18.5 12h3" opacity=".75"/></svg></span></span>
620
597
  <span data-i18n="nav.home">主页</span><b id="nav-pending" class="nav-badge hidden"></b>
621
598
  </button>
622
- <button data-view="view-stats" class="nav-btn">
623
- <span class="nav-ico"><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-7"/><path d="M3 19h18" opacity=".55"/></svg></span>
624
- <span data-i18n="nav.stats">统计</span>
599
+ <button data-view="view-plugins" class="nav-btn">
600
+ <span class="nav-ico"><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="M9 3v4h6V3M7 7h10v6a5 5 0 0 1-10 0V7ZM12 18v3"/></svg></span>
601
+ <span data-i18n="nav.plugins">插件</span>
625
602
  </button>
626
603
  <button data-view="view-settings" class="nav-btn">
627
604
  <span class="nav-ico"><svg class="settings-gear" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.09a2 2 0 0 1 1 1.73v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.51a2 2 0 0 1 1-1.73l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2Z"/><circle cx="12" cy="12" r="3"/></svg></span>
@@ -666,6 +643,8 @@
666
643
  <textarea id="fb-msg" class="fb-textarea" rows="5" maxlength="2000" data-i18n-placeholder="feedback.messagePlaceholder" placeholder="请描述遇到的问题或建议(必填,≤2000 字)"></textarea>
667
644
  <input id="fb-contact" class="fb-input" maxlength="200" data-i18n-placeholder="feedback.contactPlaceholder" placeholder="联系方式(可选):邮箱 / 微信 / B站 ID">
668
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>
669
648
  </div>
670
649
  <div class="modal-actions">
671
650
  <button id="fb-cancel" class="btn subtle" data-i18n="feedback.cancel">取消</button>
@@ -765,6 +744,21 @@
765
744
  </div>
766
745
  </div>
767
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>
768
762
  <div id="modal-rename" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="rename-session-title">
769
763
  <div class="modal-card">
770
764
  <div id="rename-session-title" class="modal-title" data-i18n="session.renameTitle">重命名会话</div>
@@ -936,9 +930,9 @@
936
930
  'conn.poll': '轮询', 'conn.pollTitle': '当前网络不支持实时推送,已降级为轮询(延迟数秒)',
937
931
  'conn.titleGroup': '{group} · {url}({ms})',
938
932
  'common.refreshing': '刷新中…', 'select.choose': '请选择', 'select.cancel': '取消',
939
- 'nav.sessions': '会话', 'nav.files': '文件', 'nav.pending': '待办', 'nav.overview': '总览', 'nav.home': '主页', 'nav.stats': '统计', 'nav.settings': '设置',
933
+ 'nav.sessions': '会话', 'nav.files': '文件', 'nav.pending': '待办', 'nav.overview': '总览', 'nav.home': '主页', 'nav.plugins': '插件', 'nav.settings': '设置',
940
934
  'overview.eyebrow': 'SYSTEM OVERVIEW', 'overview.title': '主页', 'overview.subtitle': '实时状态与需要你处理的事项',
941
- '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',
942
936
  'overview.gateway': '网关', 'overview.dsh': 'DSH 上游', 'overview.mux': '实时 mux', 'overview.host': '实时 host', 'overview.online': '在线', 'overview.offlineShort': '离线',
943
937
  'overview.attention': '需要你处理', 'overview.metrics': '运行指标', 'overview.activity': '近期活动', 'overview.dshVersion': 'DSH 版本', 'overview.gatewayVersion': '网关状态',
944
938
  'overview.activeSessions': '活跃会话', 'overview.connectionMode': '连接模式', 'overview.ws': '实时 WS', 'overview.poll': '降级轮询', 'overview.noActivity': '暂无后台活动', 'overview.noSession': '暂无会话',
@@ -946,14 +940,6 @@
946
940
  'overview.action.openSession': '打开最近会话', 'overview.action.newSession': '新建会话', 'overview.action.attention': '处理待办', 'overview.action.connect': '去设置连接', 'overview.action.refresh': '重新检查连接',
947
941
  'overview.communityEyebrow': 'COMMUNITY BOARD', 'overview.communityTitle': '公告与投票', 'overview.communityHistory': '历史公告', 'overview.communityEmpty': '暂无未读公告',
948
942
  'overview.communityPoll': '投票', 'overview.communityNotice': '公告', 'overview.communityNew': '新', 'overview.communityVote': '参与投票', 'overview.communityView': '查看公告', 'overview.communityVoted': '已参与',
949
- 'statsPage.eyebrow': '使用概览', 'statsPage.title': 'Token 统计', 'statsPage.subtitle': '按日查看 Token 用量与费用估算', 'statsPage.latest': '最新日期', 'statsPage.chartMetric': '图表指标', 'statsPage.modeTokens': 'Token 数', 'statsPage.modeCost': '费用(官方)',
950
- 'statsPage.costKicker': 'ESTIMATED COST', 'statsPage.tokenKicker': 'TOKEN VOLUME', 'statsPage.costChartLabel': '近 7 天费用趋势', 'statsPage.tokenChartLabel': '近 7 天 Token 用量趋势',
951
- 'statsPage.today': '今日', 'statsPage.tokenUnit': 'Tokens', 'statsPage.breakdown': '今日用量构成', 'statsPage.estimated': '估算费用', 'statsPage.peakShareLabel': '高峰费用占比 {n}%',
952
- 'statsPage.todayTokens': '今日 Token', 'statsPage.todayCost': '今日费用', 'statsPage.peakShare': '高峰占比', 'statsPage.costTrend': '费用趋势', 'statsPage.tokenTrend': 'Token 量趋势', 'statsPage.tokenTotal': 'Token 总量',
953
- 'statsPage.input': '未缓存输入', 'statsPage.cacheRead': '缓存命中', 'statsPage.cacheWrite': '缓存写入', 'statsPage.output': '输出',
954
- 'statsPage.peak': '高峰', 'statsPage.off': '空闲', 'statsPage.days': '近 {n} 天',
955
- 'statsPage.gatewayDown': '统计需要网关运行', 'statsPage.empty': '暂无统计,产生会话后自动聚合',
956
- 'statsPage.note': '注:Token 量按 DSH usage 事件记录,第三方 provider 需提供 usage 才能计入;费用仅对已知 DeepSeek 模型估算,与官网账单可能有出入,一切以官网为准。统计自 2026-08-17 定价生效日起。',
957
943
  'home.newSession': '+ 新会话', 'home.newWorkspace': '+ 工作区', 'home.empty': '暂无会话', 'home.createFailed': '新建会话失败', 'home.created': '会话已创建',
958
944
  'wb.unbound': '未绑定工作台', 'wb.bound': '绑定 {title}', 'wb.noProjects': '暂无项目', 'wb.noSessions': '暂无会话', 'wb.newSession': '新会话', 'wb.orderSaved': '顺序已保存到当前设备', 'wb.flatHidden': '工作台会话已收起({n} 个)', 'wb.archivedHidden': '------隐藏已归档会话------', 'wb.archivedShown': '------显示已归档会话------',
959
945
  'workspace.createTitle': '新建工作区', 'workspace.createDesc': '将在当前文件目录下创建文件夹,并自动打开新会话。', 'workspace.parent': '父目录', 'workspace.namePlaceholder': '例如:my-project', 'workspace.cancel': '取消', 'workspace.create': '创建并打开', 'workspace.nameRequired': '请输入工作区名称', 'workspace.exists': '该目录已存在', 'workspace.invalidName': '名称不能包含路径分隔符', 'workspace.createFailed': '创建工作区失败:{msg}', 'workspace.created': '工作区已创建', 'workspace.createdNoSession': '工作区已创建,但新会话未能打开',
@@ -993,12 +979,12 @@
993
979
  'groups.drawerOpen': '切换服务器组', 'groups.drawerClose': '关闭组列表', 'groups.drawerEyebrow': '快速切换', 'groups.drawerTitle': '切换服务器组', 'groups.drawerCurrent': '当前连接组',
994
980
  'groups.drawerManage': '管理服务器与组', 'groups.drawerManageDesc': '编辑、测速和调整组', 'groups.serverCount': '{count} 台服务器',
995
981
  'stream.error': '事件流错误:{msg}',
996
- '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': '已中断',
997
983
  'session.stop': '停止本轮', 'session.history': '对话', 'session.errorMsg': '会话出错:{msg}', 'session.recovering': '恢复会话中…', 'session.recoveryReady': '会话已恢复', 'session.recoveryFailed': '会话恢复失败', 'session.recoveryCached': '正在查看缓存历史',
998
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': '会话名称已更新',
999
985
  'notify.approvalTitle': '工具审批', 'notify.approvalBody': '{tool} 需要批准', 'notify.questionTitle': 'DSH 提问', 'notify.questionBody': '需要你回答',
1000
986
  'notify.permissionFailed': '通知权限申请失败:{msg}',
1001
- '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': '重试',
1002
988
  'history.offlineCache': '离线缓存 {n} 条', 'history.count': '{n} 条', 'history.empty': '还没有消息',
1003
989
  'history.queueAndCount': '队列 {q} · 历史 {n}', 'history.countOnly': '历史 {n}',
1004
990
  'role.me': '我', 'role.dsh': 'DSH',
@@ -1007,6 +993,7 @@
1007
993
  'event.systemReminder': '系统提示',
1008
994
  'block.image': '图片', 'block.thinking': '🧠 思考过程', 'block.thinkingLive': '🧠 思考中…', 'block.toolCall': '工具调用', 'block.toolResult': '工具结果', 'block.unknown': '块 · {type}',
1009
995
  'truncated': '…(截断)',
996
+ 'insights.title': '上下文与费用',
1010
997
  'stats.title': '本轮统计', 'stats.roundTitle': '本轮统计', 'stats.turnsSteps': '轮次 / 步骤',
1011
998
  'stats.llmTime': '模型耗时', 'stats.minutes': ' 分钟', 'stats.outputCache': '输出 / 缓存读',
1012
999
  'stats.ctxPressure': '上下文压力', 'stats.permission': '权限', 'stats.empty': '暂无统计数据',
@@ -1064,6 +1051,7 @@
1064
1051
  'update.phase.downloading': '正在下载…', 'update.phase.verifying': '正在校验安装包…', 'update.phase.complete': '下载完成', 'update.phase.legacy': '系统正在下载安装包;安装新版后可显示完整进度', 'update.busy': '已有更新下载正在进行',
1065
1052
  'update.downloadStarted': '开始下载,完成后会弹出安装页', 'update.downloadFailed': '无法启动下载:{msg}',
1066
1053
  'update.corrupted': '下载文件损坏,请重试', 'update.serverFileMissing': '服务器上还没有对应版本的文件,请稍后再试',
1054
+ 'update.hashMissing': '更新包缺少 SHA-256 校验值,已取消下载',
1067
1055
  'update.installUnsupported': '当前版本不支持 App 内安装,已转浏览器下载',
1068
1056
  'update.expand': '展开', 'update.collapse': '收起',
1069
1057
  'update.gatewayAheadTitle': 'App 版本需要更新', 'update.gatewayAheadBody': '网关已经更新,但当前 App 仍是较旧版本,部分功能可能缺失或显示异常。',
@@ -1076,6 +1064,7 @@
1076
1064
  'scan.promptHeader': '扫描 DSH Remote 配对二维码', 'scan.promptLive': '实时扫码', 'scan.promptPhoto': '拍照扫描', 'scan.promptGallery': '从相册选择',
1077
1065
  'scan.noPhoto': '没有拿到图片,请重试', 'scan.noQr': '未识别到二维码,请对准后重试',
1078
1066
  'scan.paired': '配对成功,正在连接', 'scan.pairedLink': '已通过二维码配对', 'scan.notPair': '这不是 DSH Remote 的配对二维码',
1067
+ 'scan.pairConfirm': '已配置过服务器,是否用以下地址替换当前连接?\n{servers}',
1079
1068
  'scan.cancelled': '已取消扫码', 'scan.failed': '扫码失败:{msg}',
1080
1069
  'settings.groupGeneral': '通用', 'settings.groupGeneralDesc': '显示工具调用、预设提示词',
1081
1070
  'settings.groupModel': '模型', 'settings.groupModelDesc': '提供方、API 密钥和模型目录',
@@ -1083,7 +1072,7 @@
1083
1072
  'settings.groupServers': '服务器', 'settings.groupServersDesc': '服务器地址、令牌与扫码连接',
1084
1073
  'settings.groupNotify': '通知', 'settings.groupNotifyDesc': '通知、提醒、后台轮询',
1085
1074
  'settings.groupTheme': '皮肤', 'settings.groupThemeDesc': '配色主题',
1086
- 'settings.groupAbout': '关于', 'settings.groupAboutDesc': 'DSH 状态、检查更新',
1075
+ 'settings.groupAbout': '关于', 'settings.groupAboutDesc': 'DSH 状态、检查更新、贡献者致谢',
1087
1076
  'settings.donate': '赞赏支持', 'settings.donateDesc': '如果 dsh-remote 帮到了你,欢迎赞赏支持开发 ☕', 'settings.donateThanks': '感谢你的支持 ☕',
1088
1077
  'settings.back': '‹ 返回',
1089
1078
  'settings.modelTitle': '模型', 'settings.modelIntro': '填入各提供方的 API 密钥即可使用其模型。', 'settings.modelRefresh': '刷新', 'settings.modelOpenConfig': '打开配置文件',
@@ -1140,7 +1129,7 @@
1140
1129
  'feedback.title': '反馈', 'feedback.githubDesc': '反馈 bug / 提建议', 'feedback.giteeDesc': '国内镜像,无需代理',
1141
1130
  'feedback.biliDesc': 'UP 动态页交流', 'feedback.copyLink': '复制项目链接', 'feedback.copyDesc': '手动分享给朋友',
1142
1131
  'feedback.copied': '项目链接已复制', 'feedback.copyFailed': '复制失败,请手动复制',
1143
- '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': '当前网关暂时无法读取日志,请更新网关或稍后重试。',
1144
1133
  'feedback.typeBug': 'Bug', 'feedback.typeSuggestion': '建议', 'feedback.typeOther': '其他',
1145
1134
  'feedback.messagePlaceholder': '请描述遇到的问题或建议(必填,≤2000 字)',
1146
1135
  'feedback.contactPlaceholder': '联系方式(可选):邮箱 / 微信 / B站 ID',
@@ -1152,6 +1141,7 @@
1152
1141
  'settings.resetTitle': '清空本地数据', 'settings.resetDesc': '令牌与缓存', 'settings.reset': '重置',
1153
1142
  'settings.confirmReset': '清除本地令牌、服务器与缓存?', 'settings.notifyDenied': '通知权限未开启',
1154
1143
  'settings.toolsShown': '已显示工具调用', 'settings.toolsHidden': '已隐藏工具调用',
1144
+ 'settings.contributors': '贡献者', 'settings.contributorsThanks': '感谢每一位让 DSH Remote 变得更好的贡献者。', 'settings.contributorsAll': '查看完整贡献记录 ↗',
1155
1145
  'settings.about': 'DSH Remote · 局域网 / Tailscale 均可访问<br>仅作为 DSH 本机 3080 端口的带认证代理',
1156
1146
  'modal.approvalTitle': '工具审批', 'modal.reject': '拒绝', 'modal.allowOnce': '允许一次',
1157
1147
  'modal.questionTitle': 'DSH 需要你回答', 'modal.later': '稍后', 'modal.submit': '提交',
@@ -1179,9 +1169,9 @@
1179
1169
  'conn.poll': 'Polling', 'conn.pollTitle': 'Realtime push is unavailable on this network; degraded to polling (a few seconds delay)',
1180
1170
  'conn.titleGroup': '{group} · {url} ({ms})',
1181
1171
  'common.refreshing': 'Refreshing…', 'select.choose': 'Choose an option', 'select.cancel': 'Cancel',
1182
- 'nav.sessions': 'Sessions', 'nav.files': 'Files', 'nav.pending': 'Inbox', 'nav.overview': 'Overview', 'nav.home': 'Home', 'nav.stats': 'Stats', 'nav.settings': 'Settings',
1172
+ 'nav.sessions': 'Sessions', 'nav.files': 'Files', 'nav.pending': 'Inbox', 'nav.overview': 'Overview', 'nav.home': 'Home', 'nav.plugins': 'Plugins', 'nav.settings': 'Settings',
1183
1173
  'overview.eyebrow': 'SYSTEM OVERVIEW', 'overview.title': 'Home', 'overview.subtitle': 'Live status and items needing your attention',
1184
- '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',
1185
1175
  'overview.gateway': 'Gateway', 'overview.dsh': 'DSH upstream', 'overview.mux': 'Live mux', 'overview.host': 'Live host', 'overview.online': 'Online', 'overview.offlineShort': 'Offline',
1186
1176
  'overview.attention': 'Needs attention', 'overview.metrics': 'Runtime metrics', 'overview.activity': 'Recent activity', 'overview.dshVersion': 'DSH version', 'overview.gatewayVersion': 'Gateway status',
1187
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',
@@ -1189,14 +1179,6 @@
1189
1179
  'overview.action.openSession': 'Open recent session', 'overview.action.newSession': 'New session', 'overview.action.attention': 'Review pending', 'overview.action.connect': 'Set up connection', 'overview.action.refresh': 'Check connection',
1190
1180
  'overview.communityEyebrow': 'COMMUNITY BOARD', 'overview.communityTitle': 'Announcements & polls', 'overview.communityHistory': 'History', 'overview.communityEmpty': 'No unread announcements',
1191
1181
  'overview.communityPoll': 'Poll', 'overview.communityNotice': 'Notice', 'overview.communityNew': 'New', 'overview.communityVote': 'Vote now', 'overview.communityView': 'View notice', 'overview.communityVoted': 'Voted',
1192
- 'statsPage.eyebrow': 'USAGE OVERVIEW', 'statsPage.title': 'Token stats', 'statsPage.subtitle': 'Daily token volume and estimated costs', 'statsPage.latest': 'Latest date', 'statsPage.chartMetric': 'Chart metric', 'statsPage.modeTokens': 'Token count', 'statsPage.modeCost': 'Cost (official)',
1193
- 'statsPage.costKicker': 'ESTIMATED COST', 'statsPage.tokenKicker': 'TOKEN VOLUME', 'statsPage.costChartLabel': 'Cost trend for the last 7 days', 'statsPage.tokenChartLabel': 'Token volume trend for the last 7 days',
1194
- 'statsPage.today': 'Today', 'statsPage.tokenUnit': 'Tokens', 'statsPage.breakdown': 'Today’s usage breakdown', 'statsPage.estimated': 'Estimated cost', 'statsPage.peakShareLabel': 'Peak cost share {n}%',
1195
- 'statsPage.todayTokens': 'Tokens today', 'statsPage.todayCost': 'Cost today', 'statsPage.peakShare': 'Peak share', 'statsPage.costTrend': 'Cost trend', 'statsPage.tokenTrend': 'Token volume trend', 'statsPage.tokenTotal': 'Total tokens',
1196
- 'statsPage.input': 'Uncached input', 'statsPage.cacheRead': 'Cache read', 'statsPage.cacheWrite': 'Cache write', 'statsPage.output': 'Output',
1197
- 'statsPage.peak': 'Peak', 'statsPage.off': 'Off-peak', 'statsPage.days': 'Last {n} days',
1198
- 'statsPage.gatewayDown': 'Stats require the gateway', 'statsPage.empty': 'No stats yet — they aggregate as sessions happen',
1199
- 'statsPage.note': 'Note: token volume uses DSH usage events; a third-party provider must expose usage to be counted. Costs are estimates for known DeepSeek models and may differ from the official bill; always defer to deepseek.com. Stats start from the 2026-08-17 pricing date.',
1200
1182
  'home.newSession': '+ New session', 'home.newWorkspace': '+ Workspace', 'home.empty': 'No sessions yet', 'home.createFailed': 'Failed to create session', 'home.created': 'Session created',
1201
1183
  'wb.unbound': 'No workbench bound', 'wb.bound': 'Bound {title}', 'wb.noProjects': 'No projects', 'wb.noSessions': 'No sessions', 'wb.newSession': 'New session', 'wb.orderSaved': 'Order saved on this device', 'wb.flatHidden': 'Workbench sessions folded ({n})', 'wb.archivedHidden': '------ Hide archived ------', 'wb.archivedShown': '------ Show archived ------',
1202
1184
  'workspace.createTitle': 'New workspace', 'workspace.createDesc': 'Create a folder in the current file directory and open a new session there.', 'workspace.parent': 'Parent folder', 'workspace.namePlaceholder': 'For example: my-project', 'workspace.cancel': 'Cancel', 'workspace.create': 'Create & open', 'workspace.nameRequired': 'Enter a workspace name', 'workspace.exists': 'That folder already exists', 'workspace.invalidName': 'The name cannot contain path separators', 'workspace.createFailed': 'Could not create workspace: {msg}', 'workspace.created': 'Workspace created', 'workspace.createdNoSession': 'Workspace created, but the new session could not be opened',
@@ -1236,12 +1218,12 @@
1236
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',
1237
1219
  'groups.drawerManage': 'Manage servers and groups', 'groups.drawerManageDesc': 'Edit, test, and organize groups', 'groups.serverCount': '{count} servers',
1238
1220
  'stream.error': 'Event stream error: {msg}',
1239
- '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',
1240
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',
1241
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',
1242
1224
  'notify.approvalTitle': 'Tool approval', 'notify.approvalBody': '{tool} needs approval', 'notify.questionTitle': 'DSH question', 'notify.questionBody': 'Needs your answer',
1243
1225
  'notify.permissionFailed': 'Notification permission failed: {msg}',
1244
- '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',
1245
1227
  'history.offlineCache': 'Offline cache {n} items', 'history.count': '{n} items', 'history.empty': 'No messages yet',
1246
1228
  'history.queueAndCount': 'Queue {q} · History {n}', 'history.countOnly': 'History {n}',
1247
1229
  'role.me': 'Me', 'role.dsh': 'DSH',
@@ -1250,6 +1232,7 @@
1250
1232
  'event.systemReminder': 'System reminder',
1251
1233
  'block.image': 'Image', 'block.thinking': '🧠 Thinking', 'block.thinkingLive': '🧠 Thinking…', 'block.toolCall': 'Tool call', 'block.toolResult': 'Tool result', 'block.unknown': 'Block · {type}',
1252
1234
  'truncated': '…(truncated)',
1235
+ 'insights.title': 'Context & costs',
1253
1236
  'stats.title': 'This round', 'stats.roundTitle': 'This round', 'stats.turnsSteps': 'Turns / steps',
1254
1237
  'stats.llmTime': 'LLM time', 'stats.minutes': ' min', 'stats.outputCache': 'Output / cache read',
1255
1238
  'stats.ctxPressure': 'Context pressure', 'stats.permission': 'Permission', 'stats.empty': 'No stats yet',
@@ -1307,6 +1290,7 @@
1307
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',
1308
1291
  'update.downloadStarted': 'Downloading, the install page will open when ready', 'update.downloadFailed': 'Could not start download: {msg}',
1309
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',
1310
1294
  'update.installUnsupported': 'This version cannot install in-app, opening browser download',
1311
1295
  'update.expand': 'Expand', 'update.collapse': 'Collapse',
1312
1296
  'update.gatewayAheadTitle': 'App update recommended', 'update.gatewayAheadBody': 'The gateway is newer than this App. Some features may be missing or display incorrectly.',
@@ -1319,6 +1303,7 @@
1319
1303
  'scan.promptHeader': 'Scan DSH Remote pairing QR', 'scan.promptLive': 'Live scan', 'scan.promptPhoto': 'Take photo', 'scan.promptGallery': 'Choose from gallery',
1320
1304
  'scan.noPhoto': 'No image received, try again', 'scan.noQr': 'No QR code detected, aim and try again',
1321
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}',
1322
1307
  'scan.cancelled': 'Scan cancelled', 'scan.failed': 'Scan failed: {msg}',
1323
1308
  'settings.groupGeneral': 'General', 'settings.groupGeneralDesc': 'Tool calls, prompt presets',
1324
1309
  'settings.groupModel': 'Models', 'settings.groupModelDesc': 'Providers, API keys, and model catalogs',
@@ -1383,7 +1368,7 @@
1383
1368
  'feedback.title': 'Feedback', 'feedback.githubDesc': 'Report bugs · suggest features', 'feedback.giteeDesc': 'Mirror in China, no proxy needed',
1384
1369
  'feedback.biliDesc': 'Chat on the UP\'s Bilibili page', 'feedback.copyLink': 'Copy project link', 'feedback.copyDesc': 'Share it manually',
1385
1370
  'feedback.copied': 'Project link copied', 'feedback.copyFailed': 'Copy failed, copy manually',
1386
- '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.',
1387
1372
  'feedback.typeBug': 'Bug', 'feedback.typeSuggestion': 'Suggestion', 'feedback.typeOther': 'Other',
1388
1373
  'feedback.messagePlaceholder': 'Describe the bug or suggestion (required, ≤2000 chars)',
1389
1374
  'feedback.contactPlaceholder': 'Contact (optional): email / WeChat / Bilibili ID',
@@ -1395,6 +1380,7 @@
1395
1380
  'settings.resetTitle': 'Clear local data', 'settings.resetDesc': 'Token and caches', 'settings.reset': 'Reset',
1396
1381
  'settings.confirmReset': 'Clear local token, servers and caches?', 'settings.notifyDenied': 'Notification permission not granted',
1397
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 ↗',
1398
1384
  'settings.about': 'DSH Remote · accessible over LAN / Tailscale<br>Authenticated proxy in front of the local DSH 3080 endpoint',
1399
1385
  'modal.approvalTitle': 'Tool approval', 'modal.reject': 'Reject', 'modal.allowOnce': 'Allow once',
1400
1386
  'modal.questionTitle': 'DSH needs your answer', 'modal.later': 'Later', 'modal.submit': 'Submit',
@@ -1416,6 +1402,9 @@
1416
1402
  <script src="motion.js"></script>
1417
1403
  <script type="module" src="morphicons-init.js"></script>
1418
1404
  <script src="plugin-center.js"></script>
1405
+ <script src="insights.js"></script>
1406
+ <script src="history.js"></script>
1407
+ <script src="link-check.js"></script>
1419
1408
  <script src="app.js"></script>
1420
1409
  </body>
1421
1410
  </html>