dsh-remote-plugin 0.6.7 → 0.6.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +31 -25
- package/README.md +30 -24
- package/apk/dsh-remote.apk +0 -0
- package/client.js +11 -9
- package/gateway-stats.cjs +26 -11
- package/gateway.cjs +494 -87
- package/index.mjs +12 -2
- package/package.json +1 -1
- package/public/admin.html +103 -7
- package/public/admin.js +24 -0
- package/public/announcements.json +12 -0
- package/public/app.js +592 -91
- package/public/desktop/desktop.css +126 -19
- package/public/desktop/desktop.html +63 -14
- package/public/desktop/desktop.js +295 -77
- package/public/index.html +145 -41
- package/public/plugin.html +151 -0
- package/public/plugin.js +179 -0
- package/public/styles.css +176 -15
- package/public/theme-vars.css +9 -0
- package/public/update.json +12 -4
- package/public/version.json +1 -1
package/public/app.js
CHANGED
|
@@ -11,6 +11,16 @@ const LS = {
|
|
|
11
11
|
set(k, v) { try { localStorage.setItem(k, v) } catch {} },
|
|
12
12
|
del(k) { try { localStorage.removeItem(k) } catch {} }
|
|
13
13
|
}
|
|
14
|
+
const CLIENT_ID = (() => {
|
|
15
|
+
try {
|
|
16
|
+
let id = sessionStorage.getItem('dshRemoteClientId')
|
|
17
|
+
if (!id) {
|
|
18
|
+
id = (globalThis.crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`)
|
|
19
|
+
sessionStorage.setItem('dshRemoteClientId', id)
|
|
20
|
+
}
|
|
21
|
+
return id
|
|
22
|
+
} catch { return '' }
|
|
23
|
+
})()
|
|
14
24
|
|
|
15
25
|
/* 离线缓存: 会话列表 + 每会话聊天记录。只在网络失败时兜底展示, 不会替代线上数据。 */
|
|
16
26
|
const CACHE = {
|
|
@@ -37,6 +47,7 @@ function writeHistoryCache(cache) {
|
|
|
37
47
|
|
|
38
48
|
const state = {
|
|
39
49
|
token: '',
|
|
50
|
+
wsTicket: { token: '', server: '', value: '', expiresAt: 0 },
|
|
40
51
|
server: '', // 当前生效的网关地址, 空 = 同源(浏览器模式)
|
|
41
52
|
servers: [], // 服务器列表: [{id,url,note,group}]
|
|
42
53
|
groups: ['默认'], // 组名列表(顺序保留)
|
|
@@ -59,10 +70,15 @@ const state = {
|
|
|
59
70
|
jobs: {}, // sessionId -> jobs
|
|
60
71
|
history: emptyHistory(),
|
|
61
72
|
errCount: 0,
|
|
73
|
+
streamInfo: {
|
|
74
|
+
mux: { status: 'idle', lastOpenAt: 0, lastCloseAt: 0, lastCloseCode: 0, lastCloseReason: '' },
|
|
75
|
+
host: { status: 'idle', lastOpenAt: 0, lastCloseAt: 0, lastCloseCode: 0, lastCloseReason: '' },
|
|
76
|
+
},
|
|
62
77
|
streamMode: 'ws', // 'ws' | 'poll'
|
|
63
78
|
pollSeq: { mux: 0, host: 0 },
|
|
64
79
|
refreshTimer: null,
|
|
65
80
|
fs: { path: null, initial: null, loaded: false, upload: null },
|
|
81
|
+
composerImages: [], // 当前草稿中的图片附件:只在发送成功后释放
|
|
66
82
|
models: { loaded: false, loading: false, groups: [], current: null, failures: [] },
|
|
67
83
|
wb: null,
|
|
68
84
|
wbProjects: [],
|
|
@@ -190,6 +206,31 @@ function apiUrl(path) {
|
|
|
190
206
|
return (state.server || '') + path
|
|
191
207
|
}
|
|
192
208
|
|
|
209
|
+
let wsTicketPromise = null
|
|
210
|
+
async function getWsTicket() {
|
|
211
|
+
const now = Date.now()
|
|
212
|
+
if (state.wsTicket.token === state.token && state.wsTicket.server === state.server &&
|
|
213
|
+
state.wsTicket.value && state.wsTicket.expiresAt > now + 15000) return state.wsTicket.value
|
|
214
|
+
if (wsTicketPromise) return wsTicketPromise
|
|
215
|
+
const token = state.token
|
|
216
|
+
const server = state.server
|
|
217
|
+
wsTicketPromise = (async () => {
|
|
218
|
+
const res = await fetch(apiUrl('/api/ws-ticket'), {
|
|
219
|
+
method: 'POST',
|
|
220
|
+
headers: {
|
|
221
|
+
authorization: 'Bearer ' + token,
|
|
222
|
+
'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web',
|
|
223
|
+
}
|
|
224
|
+
})
|
|
225
|
+
if (!res.ok) throw new Error('ws ticket HTTP ' + res.status)
|
|
226
|
+
const data = await res.json()
|
|
227
|
+
if (!data?.ticket || !Number(data.expiresAt)) throw new Error('invalid ws ticket')
|
|
228
|
+
state.wsTicket = { token, server, value: data.ticket, expiresAt: Number(data.expiresAt) }
|
|
229
|
+
return data.ticket
|
|
230
|
+
})()
|
|
231
|
+
try { return await wsTicketPromise } finally { wsTicketPromise = null }
|
|
232
|
+
}
|
|
233
|
+
|
|
193
234
|
function updateBase() {
|
|
194
235
|
const configured = String(state.server || '').replace(/\/+$/, '')
|
|
195
236
|
if (configured) return configured
|
|
@@ -737,6 +778,10 @@ function deleteGroup(name) {
|
|
|
737
778
|
/* ---------------- 事件流 (WebSocket + 轮询降级) ---------------- */
|
|
738
779
|
const streams = {}
|
|
739
780
|
state.streamsOk = { mux: false, host: false }
|
|
781
|
+
const streamMeta = {
|
|
782
|
+
mux: { generation: 0, attempt: 0, failures: 0, retryTimer: null },
|
|
783
|
+
host: { generation: 0, attempt: 0, failures: 0, retryTimer: null },
|
|
784
|
+
}
|
|
740
785
|
let pollTimer = null
|
|
741
786
|
let wsRetryTimer = null
|
|
742
787
|
let connTickTimer = null
|
|
@@ -744,10 +789,40 @@ let reconnectInfo = null
|
|
|
744
789
|
|
|
745
790
|
function clearStreamTimers(ws) {
|
|
746
791
|
if (!ws) return
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
792
|
+
if (ws._retryTimer) clearTimeout(ws._retryTimer)
|
|
793
|
+
ws._retryTimer = null
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function streamIsCurrent(kind, ws, generation) {
|
|
797
|
+
return streams[kind] === ws && streamMeta[kind].generation === generation
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function aggregateStreamFailures() {
|
|
801
|
+
state.errCount = Math.max(streamMeta.mux.failures, streamMeta.host.failures)
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function markStreamInfo(kind, patch) {
|
|
805
|
+
state.streamInfo[kind] = { ...state.streamInfo[kind], ...patch }
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function allStreamsOpen() {
|
|
809
|
+
return streams.mux?.readyState === WebSocket.OPEN && streams.host?.readyState === WebSocket.OPEN
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function clearStreamRetry(kind) {
|
|
813
|
+
const meta = streamMeta[kind]
|
|
814
|
+
if (meta.retryTimer) clearTimeout(meta.retryTimer)
|
|
815
|
+
meta.retryTimer = null
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function closeStream(kind) {
|
|
819
|
+
const meta = streamMeta[kind]
|
|
820
|
+
clearStreamRetry(kind)
|
|
821
|
+
meta.generation++
|
|
822
|
+
const ws = streams[kind]
|
|
823
|
+
streams[kind] = null
|
|
824
|
+
state.streamsOk[kind] = false
|
|
825
|
+
try { ws?.close() } catch {}
|
|
751
826
|
}
|
|
752
827
|
|
|
753
828
|
function clearConnTick() {
|
|
@@ -779,15 +854,23 @@ function clearReconnect() {
|
|
|
779
854
|
|
|
780
855
|
function openStreams() {
|
|
781
856
|
if (!state.token) return
|
|
782
|
-
if (state.streamMode
|
|
783
|
-
state.streamMode = 'ws'
|
|
784
|
-
clearReconnect()
|
|
857
|
+
if (state.streamMode !== 'poll') state.streamMode = 'ws'
|
|
785
858
|
openStream('mux', onMuxFrame, true)
|
|
786
859
|
openStream('host', onHostFrame, false)
|
|
787
860
|
}
|
|
788
861
|
|
|
789
|
-
function openStream(kind, handler, refreshOnOpen, isRestore) {
|
|
862
|
+
function openStream(kind, handler, refreshOnOpen, isRestore, ticket = null) {
|
|
790
863
|
if (!state.token) return
|
|
864
|
+
if (ticket === null) {
|
|
865
|
+
const token = state.token
|
|
866
|
+
void getWsTicket().then((value) => {
|
|
867
|
+
if (state.token === token) openStream(kind, handler, refreshOnOpen, isRestore, value)
|
|
868
|
+
}).catch(() => {
|
|
869
|
+
// 兼容旧网关/插件副本: ticket 接口不可用时临时回退旧 token 握手。
|
|
870
|
+
if (state.token === token) openStream(kind, handler, refreshOnOpen, isRestore, '')
|
|
871
|
+
})
|
|
872
|
+
return
|
|
873
|
+
}
|
|
791
874
|
let base
|
|
792
875
|
if (state.server) {
|
|
793
876
|
base = state.server.replace(/^http/, 'ws')
|
|
@@ -796,31 +879,40 @@ function openStream(kind, handler, refreshOnOpen, isRestore) {
|
|
|
796
879
|
base = `${proto}//${location.host}`
|
|
797
880
|
}
|
|
798
881
|
const clientMark = CAP?.isNativePlatform?.() ? 'app' : 'web'
|
|
799
|
-
const
|
|
800
|
-
|
|
882
|
+
const auth = ticket ? `ticket=${encodeURIComponent(ticket)}` : `token=${encodeURIComponent(state.token)}`
|
|
883
|
+
const clientId = CLIENT_ID ? `&clientId=${encodeURIComponent(CLIENT_ID)}` : ''
|
|
884
|
+
const streamUrl = `${base}/api/events.${kind}?${auth}&client=${clientMark}${clientId}`
|
|
885
|
+
const current = streams[kind]
|
|
886
|
+
if (current?._streamUrl === streamUrl &&
|
|
887
|
+
(current.readyState === WebSocket.OPEN || current.readyState === WebSocket.CONNECTING)) return
|
|
888
|
+
if (current && current._streamUrl !== streamUrl) {
|
|
889
|
+
streamMeta[kind].attempt = 0
|
|
890
|
+
streamMeta[kind].failures = 0
|
|
891
|
+
aggregateStreamFailures()
|
|
892
|
+
}
|
|
893
|
+
closeStream(kind)
|
|
894
|
+
const meta = streamMeta[kind]
|
|
895
|
+
const generation = meta.generation
|
|
896
|
+
const ws = new WebSocket(streamUrl)
|
|
801
897
|
streams[kind] = ws
|
|
802
|
-
ws.
|
|
803
|
-
ws.
|
|
898
|
+
ws._streamUrl = streamUrl
|
|
899
|
+
ws._generation = generation
|
|
804
900
|
ws._isRestore = !!isRestore
|
|
805
901
|
ws.onopen = () => {
|
|
902
|
+
if (!streamIsCurrent(kind, ws, generation)) return
|
|
806
903
|
state.streamsOk[kind] = true
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
904
|
+
markStreamInfo(kind, { status: 'open', lastOpenAt: Date.now(), lastCloseCode: 0, lastCloseReason: '' })
|
|
905
|
+
meta.attempt = 0
|
|
906
|
+
meta.failures = 0
|
|
907
|
+
aggregateStreamFailures()
|
|
810
908
|
clearStreamTimers(ws)
|
|
811
|
-
//
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
try { ws.close() } catch {}
|
|
819
|
-
}
|
|
820
|
-
}, 10000)
|
|
821
|
-
// 重连成功:切回 WS 并停止轮询
|
|
822
|
-
if (state.streamMode === 'poll') { stopPolling(); state.streamMode = 'ws' }
|
|
823
|
-
if (streams.mux?.readyState === WebSocket.OPEN && streams.host?.readyState === WebSocket.OPEN) clearReconnect()
|
|
909
|
+
// DSH mux/host 是只下行 WebSocket, 浏览器不能发送应用层 ping。
|
|
910
|
+
// 网关负责 RFC6455 Ping/Pong, 前端只监听业务帧和 close 事件。
|
|
911
|
+
if (state.streamMode === 'poll' && allStreamsOpen()) {
|
|
912
|
+
stopPolling()
|
|
913
|
+
state.streamMode = 'ws'
|
|
914
|
+
}
|
|
915
|
+
if (allStreamsOpen()) clearReconnect()
|
|
824
916
|
updateConn()
|
|
825
917
|
// mux 每次(重)连接都会重放“仍待处理”的审批/提问基线:
|
|
826
918
|
// 先清空旧列表, 避免“桌面已自定义回答, 手机漏收 question/resolved”后永久残留。
|
|
@@ -832,9 +924,8 @@ function openStream(kind, handler, refreshOnOpen, isRestore) {
|
|
|
832
924
|
if (refreshOnOpen) refreshAll()
|
|
833
925
|
}
|
|
834
926
|
ws.onmessage = (msg) => {
|
|
835
|
-
ws
|
|
927
|
+
if (!streamIsCurrent(kind, ws, generation)) return
|
|
836
928
|
state.streamsOk[kind] = true
|
|
837
|
-
state.errCount = 0
|
|
838
929
|
updateConn()
|
|
839
930
|
try {
|
|
840
931
|
const full = JSON.parse(msg.data)
|
|
@@ -843,33 +934,33 @@ function openStream(kind, handler, refreshOnOpen, isRestore) {
|
|
|
843
934
|
}
|
|
844
935
|
ws.onclose = () => {
|
|
845
936
|
clearStreamTimers(ws)
|
|
937
|
+
if (!streamIsCurrent(kind, ws, generation)) return
|
|
938
|
+
streams[kind] = null
|
|
846
939
|
state.streamsOk[kind] = false
|
|
847
|
-
|
|
940
|
+
markStreamInfo(kind, {
|
|
941
|
+
status: 'closed',
|
|
942
|
+
lastCloseAt: Date.now(),
|
|
943
|
+
lastCloseCode: Number(ws.code) || 0,
|
|
944
|
+
lastCloseReason: String(ws.reason || ''),
|
|
945
|
+
})
|
|
946
|
+
meta.failures++
|
|
947
|
+
aggregateStreamFailures()
|
|
848
948
|
updateConn()
|
|
849
949
|
if (!navigator.onLine) { clearReconnect(); return }
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
if (ws._isRestore && streams[kind] === ws) {
|
|
853
|
-
const attempt = ws._attempt || 0
|
|
854
|
-
ws._attempt = attempt + 1
|
|
855
|
-
const baseDelay = Math.min(1200 * Math.pow(2, attempt), 30000)
|
|
856
|
-
const delay = Math.round(baseDelay * (0.8 + Math.random() * 0.4))
|
|
857
|
-
setTimeout(() => openStream(kind, handler, refreshOnOpen, true), delay)
|
|
858
|
-
}
|
|
859
|
-
return
|
|
860
|
-
}
|
|
861
|
-
// 连续失败 3 次 -> 降级为轮询
|
|
862
|
-
if (state.errCount >= 3) { enterPollMode(); return }
|
|
950
|
+
// 任一通道连续失败 3 次就降级轮询;另一个通道不会清零它的失败计数。
|
|
951
|
+
if (state.streamMode !== 'poll' && meta.failures >= 3) { enterPollMode(); return }
|
|
863
952
|
// 多服务器: 连续掉线若干次就重测速, 自动换到当前可达的最快地址
|
|
864
|
-
if (state.servers.length &&
|
|
865
|
-
//
|
|
866
|
-
const attempt =
|
|
867
|
-
|
|
868
|
-
const baseDelay = Math.min(1200 * Math.pow(2, attempt), 30000)
|
|
953
|
+
if (state.servers.length && meta.failures % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
|
|
954
|
+
// VPN/跨地域链路使用更宽松的指数退避: 1.5s 起步, 最大 60s, 带 20% 抖动。
|
|
955
|
+
const attempt = meta.attempt++
|
|
956
|
+
const baseDelay = Math.min(1500 * Math.pow(2, attempt), 60000)
|
|
869
957
|
const delay = Math.round(baseDelay * (0.8 + Math.random() * 0.4))
|
|
870
958
|
setReconnect(delay)
|
|
871
|
-
|
|
872
|
-
|
|
959
|
+
clearStreamRetry(kind)
|
|
960
|
+
meta.retryTimer = setTimeout(() => {
|
|
961
|
+
meta.retryTimer = null
|
|
962
|
+
if (state.token && navigator.onLine) openStream(kind, handler, refreshOnOpen, state.streamMode === 'poll')
|
|
963
|
+
}, delay)
|
|
873
964
|
}
|
|
874
965
|
ws.onerror = () => { try { ws.close() } catch {} }
|
|
875
966
|
}
|
|
@@ -880,10 +971,8 @@ function enterPollMode() {
|
|
|
880
971
|
state.streamMode = 'poll'
|
|
881
972
|
state.pollSeq = { mux: 0, host: 0 }
|
|
882
973
|
state.streamsOk = { mux: false, host: false }
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
streams.mux = null
|
|
886
|
-
streams.host = null
|
|
974
|
+
closeStream('mux')
|
|
975
|
+
closeStream('host')
|
|
887
976
|
refreshAll()
|
|
888
977
|
startPolling()
|
|
889
978
|
updateConn()
|
|
@@ -952,8 +1041,8 @@ async function pollKind(kind) {
|
|
|
952
1041
|
function tryRestoreWs() {
|
|
953
1042
|
if (state.streamMode !== 'poll' || !state.token) return
|
|
954
1043
|
// 轮询继续跑,等 WS 真正 onopen 后再切回,避免重连窗口丢事件
|
|
955
|
-
openStream('mux', onMuxFrame, true, true)
|
|
956
|
-
openStream('host', onHostFrame, false, true)
|
|
1044
|
+
if (!streams.mux && !streamMeta.mux.retryTimer) openStream('mux', onMuxFrame, true, true)
|
|
1045
|
+
if (!streams.host && !streamMeta.host.retryTimer) openStream('host', onHostFrame, false, true)
|
|
957
1046
|
}
|
|
958
1047
|
|
|
959
1048
|
/* 回前台恢复: 强制重排修复 MIUI WebView 后台切回时 sticky 顶栏不绘制的问题 */
|
|
@@ -994,8 +1083,8 @@ setInterval(() => {
|
|
|
994
1083
|
/* 网络感知: 离线立刻关 WS + 显示离线, 在线立即重连 */
|
|
995
1084
|
window.addEventListener('offline', () => {
|
|
996
1085
|
clearReconnect()
|
|
997
|
-
|
|
998
|
-
|
|
1086
|
+
closeStream('mux')
|
|
1087
|
+
closeStream('host')
|
|
999
1088
|
if (state.streamMode === 'poll') stopPolling()
|
|
1000
1089
|
updateConn()
|
|
1001
1090
|
})
|
|
@@ -1174,6 +1263,29 @@ async function refreshWorkbench() {
|
|
|
1174
1263
|
state.wbProjects = []
|
|
1175
1264
|
state.wbArchived = []
|
|
1176
1265
|
}
|
|
1266
|
+
// 以磁盘实际目录为准同步工作台项目:删除目录后不再残留,新增子目录自动收纳。
|
|
1267
|
+
if (state.wb?.bound && state.wb.path) {
|
|
1268
|
+
try {
|
|
1269
|
+
const listRes = await fetch(fsApiUrl('/list', { path: state.wb.path }), { headers: fsHeaders() })
|
|
1270
|
+
if (listRes.ok) {
|
|
1271
|
+
const listData = await listRes.json().catch(() => ({}))
|
|
1272
|
+
if (Array.isArray(listData.entries)) {
|
|
1273
|
+
const diskDirs = new Set(listData.entries.filter(e => e.type === 'dir').map(e => wbPathKey(wbJoin(state.wb.path, e.name))))
|
|
1274
|
+
state.wbProjects = state.wbProjects.filter(w => diskDirs.has(wbPathKey(w.path)))
|
|
1275
|
+
const have = new Set(state.wbProjects.map(w => wbPathKey(w.path)))
|
|
1276
|
+
for (const entry of listData.entries) {
|
|
1277
|
+
if (entry.type !== 'dir') continue
|
|
1278
|
+
const projectPath = wbJoin(state.wb.path, entry.name)
|
|
1279
|
+
if (have.has(wbPathKey(projectPath))) continue
|
|
1280
|
+
try {
|
|
1281
|
+
const created = await rpc('workspace.create', { path: projectPath })
|
|
1282
|
+
if (created?.workspace) { state.wbProjects.push(created.workspace); have.add(wbPathKey(projectPath)) }
|
|
1283
|
+
} catch {}
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
} catch {}
|
|
1288
|
+
}
|
|
1177
1289
|
renderWorkbench()
|
|
1178
1290
|
renderSessions()
|
|
1179
1291
|
}
|
|
@@ -1201,15 +1313,19 @@ function renderWorkbench() {
|
|
|
1201
1313
|
panel.innerHTML = `<div class="wb-empty">${esc(t('wb.noProjects'))}</div>`
|
|
1202
1314
|
return
|
|
1203
1315
|
}
|
|
1316
|
+
const archivedSet = new Set(state.wbArchived || [])
|
|
1204
1317
|
panel.innerHTML = projects.map(w => {
|
|
1205
1318
|
const id = String(w.workspaceId || '')
|
|
1206
1319
|
const open = !!state.wbOpenProjects[id]
|
|
1207
|
-
const sessions = (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(Boolean)
|
|
1320
|
+
const sessions = (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(Boolean).filter(s => !archivedSet.has(s.sessionId))
|
|
1208
1321
|
const body = open ? `<div class="wb-sessions">${sessions.length ? sessions.map(s => `
|
|
1209
|
-
<
|
|
1210
|
-
<
|
|
1211
|
-
|
|
1212
|
-
|
|
1322
|
+
<div class="session-swipe" data-session-swipe data-id="${esc(s.sessionId)}">
|
|
1323
|
+
<button class="wb-session" type="button" data-wb-session="${esc(s.sessionId)}">
|
|
1324
|
+
<span class="wb-session-title">${esc(titleOf(s))}</span>
|
|
1325
|
+
<span class="wb-session-meta">${s.running ? esc(t('sessions.running')) : esc(fmtTime(s.updatedAt))}</span>
|
|
1326
|
+
</button>
|
|
1327
|
+
<button type="button" class="sc-archive-btn" data-archive-session="${esc(s.sessionId)}">${esc(t('session.archive'))}</button>
|
|
1328
|
+
</div>`).join('') : `<div class="wb-empty">${esc(t('wb.noSessions'))}</div>`}</div>` : ''
|
|
1213
1329
|
return `<div class="wb-project ${open ? 'open' : ''}" data-wb-project="${esc(id)}">
|
|
1214
1330
|
<div class="wb-project-head">
|
|
1215
1331
|
<span class="wb-chevron" aria-hidden="true">${open ? '▾' : '▸'}</span>
|
|
@@ -1250,8 +1366,12 @@ function renderSessions() {
|
|
|
1250
1366
|
const wbIds = new Set()
|
|
1251
1367
|
if (state.wb?.bound) for (const w of state.wbProjects) for (const id of (w.sessionIds || [])) wbIds.add(id)
|
|
1252
1368
|
const root = workbenchRoot()
|
|
1253
|
-
const visible = allItems.filter(s => !(state.wb?.bound && (wbIds.has(s.sessionId) || wbStrictInside(s.cwd, root))))
|
|
1254
1369
|
const archivedSet = new Set(state.wbArchived || [])
|
|
1370
|
+
const visible = allItems.filter(s => {
|
|
1371
|
+
if (!state.wb?.bound) return true
|
|
1372
|
+
if (archivedSet.has(s.sessionId)) return true
|
|
1373
|
+
return !(wbIds.has(s.sessionId) || wbStrictInside(s.cwd, root))
|
|
1374
|
+
})
|
|
1255
1375
|
const archived = visible.filter(s => archivedSet.has(s.sessionId))
|
|
1256
1376
|
const main = visible.filter(s => !archivedSet.has(s.sessionId))
|
|
1257
1377
|
const showArchived = LS.get('showArchivedV1', '0') === '1'
|
|
@@ -1325,6 +1445,8 @@ async function openSession(id) {
|
|
|
1325
1445
|
}
|
|
1326
1446
|
|
|
1327
1447
|
function closeSession() {
|
|
1448
|
+
setComposerFullscreen(false)
|
|
1449
|
+
clearComposerImages()
|
|
1328
1450
|
state.current = null
|
|
1329
1451
|
state.history = emptyHistory()
|
|
1330
1452
|
document.body.classList.remove('in-session')
|
|
@@ -1337,6 +1459,7 @@ function bindNativeBack() {
|
|
|
1337
1459
|
if (!CAP?.isNativePlatform?.()) return
|
|
1338
1460
|
try {
|
|
1339
1461
|
CAP.Plugins?.App?.addListener?.('backButton', () => {
|
|
1462
|
+
if ($('composer-wrap')?.classList.contains('fs')) { setComposerFullscreen(false); return }
|
|
1340
1463
|
const openModal = [...document.querySelectorAll('.modal')].find(m => !m.classList.contains('hidden'))
|
|
1341
1464
|
if (openModal) { if (openModal.id === 'modal-notes') closeNotesModal(); else if (openModal.id === 'modal-archive') closeArchiveConfirm(); else openModal.classList.add('hidden'); return } // 先关弹窗
|
|
1342
1465
|
if (document.body.classList.contains('in-session')) { closeSession(); return } // 会话页 → 回主页
|
|
@@ -1663,7 +1786,7 @@ function eventHtml(entry, ctx = {}) {
|
|
|
1663
1786
|
const blocks = msg.content || data.content || []
|
|
1664
1787
|
const sysText = type === 'user/message' ? systemReminderText(blocks) : ''
|
|
1665
1788
|
if (sysText) {
|
|
1666
|
-
inner = `<details class="event" data-seq="${seq}"><summary>${esc(t('event.systemReminder'))}</summary><pre>${esc(truncate(sysText,
|
|
1789
|
+
inner = `<details class="event event-detail" data-seq="${seq}"><summary>${esc(t('event.systemReminder'))}</summary><pre>${esc(truncate(sysText, 4000))}</pre></details>`
|
|
1667
1790
|
} else {
|
|
1668
1791
|
inner = `<div class="msg ${esc(role)}" data-seq="${seq}"><div class="role">${esc(role === 'user' ? t('role.me') : t('role.dsh'))}</div>${blocks.map(blockHtml).join('')}</div>`
|
|
1669
1792
|
}
|
|
@@ -1841,27 +1964,147 @@ async function runSlashCommand(text) {
|
|
|
1841
1964
|
return false
|
|
1842
1965
|
}
|
|
1843
1966
|
|
|
1967
|
+
function bytesToBase64(bytes) {
|
|
1968
|
+
let binary = ''
|
|
1969
|
+
const step = 0x8000
|
|
1970
|
+
for (let i = 0; i < bytes.length; i += step) {
|
|
1971
|
+
binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + step, bytes.length)))
|
|
1972
|
+
}
|
|
1973
|
+
return btoa(binary)
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
function imageTypeOk(type) {
|
|
1977
|
+
return ['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(String(type || '').toLowerCase())
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
function renderComposerImages() {
|
|
1981
|
+
const box = $('composer-attachments')
|
|
1982
|
+
if (!box) return
|
|
1983
|
+
document.body.classList.toggle('has-composer-images', state.composerImages.length > 0)
|
|
1984
|
+
box.classList.toggle('hidden', state.composerImages.length === 0)
|
|
1985
|
+
box.innerHTML = state.composerImages.map(item => `<div class="composer-attachment" title="${esc(item.file.name || t('block.image'))}">
|
|
1986
|
+
<img src="${esc(item.url)}" alt="${esc(item.file.name || t('block.image'))}">
|
|
1987
|
+
<button type="button" class="composer-attachment-remove" data-remove-image="${esc(item.id)}" aria-label="${esc(t('composer.removeImage'))}">×</button>
|
|
1988
|
+
</div>`).join('')
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
function clearComposerImages() {
|
|
1992
|
+
state.composerImages.splice(0).forEach(item => { try { URL.revokeObjectURL(item.url) } catch {} })
|
|
1993
|
+
renderComposerImages()
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
function removeComposerImage(id) {
|
|
1997
|
+
const index = state.composerImages.findIndex(item => item.id === id)
|
|
1998
|
+
if (index < 0) return
|
|
1999
|
+
const [item] = state.composerImages.splice(index, 1)
|
|
2000
|
+
try { URL.revokeObjectURL(item.url) } catch {}
|
|
2001
|
+
renderComposerImages()
|
|
2002
|
+
toast(t('composer.imageRemoved'), 'ok')
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
function addComposerImages(files) {
|
|
2006
|
+
const incoming = Array.from(files || []).filter(Boolean)
|
|
2007
|
+
if (!incoming.length) return
|
|
2008
|
+
if (state.composerImages.length + incoming.length > 20) {
|
|
2009
|
+
toast(t('composer.imageLimit', { count: 20 }), 'err')
|
|
2010
|
+
return
|
|
2011
|
+
}
|
|
2012
|
+
for (const file of incoming) {
|
|
2013
|
+
if (!imageTypeOk(file.type)) { toast(t('composer.imageUnsupported'), 'err'); continue }
|
|
2014
|
+
if (file.size > 3.5 * 1024 * 1024) { toast(t('composer.imageTooLarge', { size: '3.5 MB' }), 'err'); continue }
|
|
2015
|
+
state.composerImages.push({ id: uuid(), file, url: URL.createObjectURL(file) })
|
|
2016
|
+
}
|
|
2017
|
+
renderComposerImages()
|
|
2018
|
+
if (incoming.length) toast(t('composer.imageAdded'), 'ok')
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
function dataUrlToFile(dataUrl, name = 'photo.jpg') {
|
|
2022
|
+
const m = /^data:([^;,]+);base64,(.*)$/i.exec(String(dataUrl || ''))
|
|
2023
|
+
if (!m) return null
|
|
2024
|
+
const bytes = Uint8Array.from(atob(m[2]), c => c.charCodeAt(0))
|
|
2025
|
+
return new File([bytes], name, { type: m[1] })
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
async function captureComposerImage(source) {
|
|
2029
|
+
if (!CAP?.isNativePlatform?.()) {
|
|
2030
|
+
const input = $(source === 'CAMERA' ? 'composer-camera-input' : 'composer-gallery-input')
|
|
2031
|
+
input?.click()
|
|
2032
|
+
return
|
|
2033
|
+
}
|
|
2034
|
+
const camera = CAP.Plugins?.Camera
|
|
2035
|
+
if (!camera?.getPhoto) { toast(t('scan.unsupported'), 'err'); return }
|
|
2036
|
+
try {
|
|
2037
|
+
if (source === 'CAMERA') {
|
|
2038
|
+
const perm = await camera.requestPermissions?.({ permissions: ['camera'] })
|
|
2039
|
+
if (perm && perm.camera !== 'granted') { toast(t('scan.permissionDenied'), 'err'); return }
|
|
2040
|
+
}
|
|
2041
|
+
const photo = await camera.getPhoto({
|
|
2042
|
+
resultType: 'dataUrl', source: source === 'PHOTOS' ? 'PHOTOS' : 'CAMERA', quality: 85,
|
|
2043
|
+
correctOrientation: true, saveToGallery: false
|
|
2044
|
+
})
|
|
2045
|
+
const file = dataUrlToFile(photo?.dataUrl, `dsh-image-${Date.now()}.${photo?.format || 'jpg'}`)
|
|
2046
|
+
if (file) addComposerImages([file])
|
|
2047
|
+
} catch (e) {
|
|
2048
|
+
const msg = String(e?.message || e || '')
|
|
2049
|
+
if (!/cancel/i.test(msg)) toast(t('composer.imageReadFailed', { msg }), 'err')
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
function toggleComposerImageMenu() {
|
|
2054
|
+
const menu = $('composer-image-menu')
|
|
2055
|
+
if (!menu) return
|
|
2056
|
+
const show = menu.classList.contains('hidden')
|
|
2057
|
+
menu.classList.toggle('hidden', !show)
|
|
2058
|
+
$('btn-image')?.classList.toggle('active', show)
|
|
2059
|
+
}
|
|
2060
|
+
|
|
1844
2061
|
async function sendSessionText(text) {
|
|
2062
|
+
return sendSessionContent(text, [])
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
async function sendSessionContent(text, images) {
|
|
1845
2066
|
const clean = String(text || '').trim()
|
|
1846
|
-
if (!clean || !state.current) return false
|
|
1847
|
-
if (await runSlashCommand(clean)) return true
|
|
1848
|
-
$('btn-send').
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
content
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
2067
|
+
if ((!clean && !images.length) || !state.current) return false
|
|
2068
|
+
if (images.length === 0 && clean && await runSlashCommand(clean)) return true
|
|
2069
|
+
const buttons = [$('btn-send'), $('btn-fs-send')].filter(Boolean)
|
|
2070
|
+
buttons.forEach(button => { button.disabled = true })
|
|
2071
|
+
try {
|
|
2072
|
+
const content = [...await encodeComposerImagesFor(images)]
|
|
2073
|
+
if (clean) content.push({ type: 'text', text: clean })
|
|
2074
|
+
const v = await safeRpc('session.prompt', {
|
|
2075
|
+
sessionId: state.current,
|
|
2076
|
+
mode: 'queue',
|
|
2077
|
+
content
|
|
2078
|
+
}, t('send.failed'))
|
|
2079
|
+
if (v?.accepted) { toast(images.length ? t('send.imageSent') : (clean.startsWith('/') ? t('send.commandSent') : t('send.sent')), 'ok'); return true }
|
|
2080
|
+
if (v?.command?.text) { toast(t('send.commandExecuted'), 'ok'); return true }
|
|
2081
|
+
return false
|
|
2082
|
+
} catch (e) {
|
|
2083
|
+
toast(t('composer.imageReadFailed', { msg: e?.message || e }), 'err')
|
|
2084
|
+
return false
|
|
2085
|
+
} finally {
|
|
2086
|
+
buttons.forEach(button => { button.disabled = false })
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
async function encodeComposerImagesFor(images) {
|
|
2091
|
+
return Promise.all(images.map(async item => ({
|
|
2092
|
+
type: 'image', mediaType: item.file.type, data: bytesToBase64(new Uint8Array(await item.file.arrayBuffer())),
|
|
2093
|
+
...(item.file.name ? { name: item.file.name } : {})
|
|
2094
|
+
})))
|
|
1858
2095
|
}
|
|
1859
2096
|
|
|
1860
2097
|
async function sendMessage() {
|
|
1861
2098
|
const input = $('composer-input')
|
|
1862
2099
|
const text = input.value.trim()
|
|
1863
|
-
|
|
1864
|
-
if (
|
|
2100
|
+
const images = state.composerImages.slice()
|
|
2101
|
+
if ((!text && !images.length) || !state.current) return
|
|
2102
|
+
if (images.length && text.startsWith('/')) { toast(t('composer.imageSlashUnsupported'), 'err'); return }
|
|
2103
|
+
if (await sendSessionContent(text, images)) {
|
|
2104
|
+
input.value = ''
|
|
2105
|
+
autosize(input)
|
|
2106
|
+
clearComposerImages()
|
|
2107
|
+
}
|
|
1865
2108
|
}
|
|
1866
2109
|
|
|
1867
2110
|
function hideComposerMenu() {
|
|
@@ -2031,13 +2274,13 @@ async function confirmArchiveSession() {
|
|
|
2031
2274
|
let swipeTracking = null
|
|
2032
2275
|
let swipeSuppressClickUntil = 0
|
|
2033
2276
|
function closeRevealedSwipes(except = null) {
|
|
2034
|
-
document.querySelectorAll('
|
|
2277
|
+
document.querySelectorAll('.session-swipe.revealed').forEach(row => {
|
|
2035
2278
|
if (row !== except) row.classList.remove('revealed')
|
|
2036
2279
|
})
|
|
2037
2280
|
}
|
|
2038
2281
|
function bindSessionSwipe() {
|
|
2039
|
-
const
|
|
2040
|
-
list.addEventListener('touchstart', e => {
|
|
2282
|
+
const containers = [$('session-list'), $('wb-panel')].filter(Boolean)
|
|
2283
|
+
for (const list of containers) list.addEventListener('touchstart', e => {
|
|
2041
2284
|
if (e.touches.length !== 1) return
|
|
2042
2285
|
const row = e.target.closest('[data-session-swipe]')
|
|
2043
2286
|
if (!row) return
|
|
@@ -2051,7 +2294,7 @@ function bindSessionSwipe() {
|
|
|
2051
2294
|
axis: null
|
|
2052
2295
|
}
|
|
2053
2296
|
}, { passive: true })
|
|
2054
|
-
list.addEventListener('touchmove', e => {
|
|
2297
|
+
for (const list of containers) list.addEventListener('touchmove', e => {
|
|
2055
2298
|
if (!swipeTracking || e.touches.length !== 1) return
|
|
2056
2299
|
const touch = e.touches[0]
|
|
2057
2300
|
const dx = touch.clientX - swipeTracking.startX
|
|
@@ -2064,7 +2307,7 @@ function bindSessionSwipe() {
|
|
|
2064
2307
|
const offset = Math.max(-92, Math.min(0, swipeTracking.offset + dx))
|
|
2065
2308
|
swipeTracking.row.style.setProperty('--swipe-x', offset + 'px')
|
|
2066
2309
|
}, { passive: false })
|
|
2067
|
-
list.addEventListener('touchend', () => {
|
|
2310
|
+
for (const list of containers) list.addEventListener('touchend', () => {
|
|
2068
2311
|
if (!swipeTracking) return
|
|
2069
2312
|
if (swipeTracking.axis === 'x') {
|
|
2070
2313
|
const row = swipeTracking.row
|
|
@@ -2075,10 +2318,99 @@ function bindSessionSwipe() {
|
|
|
2075
2318
|
}
|
|
2076
2319
|
swipeTracking = null
|
|
2077
2320
|
}, { passive: true })
|
|
2078
|
-
list.addEventListener('touchcancel', () => { swipeTracking = null }, { passive: true })
|
|
2321
|
+
for (const list of containers) list.addEventListener('touchcancel', () => { swipeTracking = null }, { passive: true })
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
/* ---------------- 系统总览 / 待办 ---------------- */
|
|
2325
|
+
function renderOverview() {
|
|
2326
|
+
const ring = $('overview-pulse-ring')
|
|
2327
|
+
if (!ring) return
|
|
2328
|
+
const checks = {
|
|
2329
|
+
gateway: !!state.token && !!state.server,
|
|
2330
|
+
dsh: !!state.hostInfo,
|
|
2331
|
+
mux: !!state.streamsOk?.mux,
|
|
2332
|
+
host: !!state.streamsOk?.host
|
|
2333
|
+
}
|
|
2334
|
+
const online = Object.values(checks).filter(Boolean).length
|
|
2335
|
+
const status = online === 4 ? 'nominal' : online > 0 ? 'degraded' : 'offline'
|
|
2336
|
+
const pulseCard = document.querySelector('.overview-pulse-card')
|
|
2337
|
+
if (pulseCard) {
|
|
2338
|
+
pulseCard.classList.remove('status-nominal', 'status-degraded', 'status-offline')
|
|
2339
|
+
pulseCard.classList.add('status-' + status)
|
|
2340
|
+
}
|
|
2341
|
+
ring.style.setProperty('--pulse-pct', `${online / 4 * 100}%`)
|
|
2342
|
+
$('overview-health').textContent = online === 4 ? t('overview.live') : online ? `${online}/4` : t('overview.offlineCore')
|
|
2343
|
+
$('overview-health-caption').textContent = online === 4 ? t('overview.allLinked') : online ? t('overview.components', { n: online }) : t('overview.offlineShort')
|
|
2344
|
+
$('overview-status').textContent = t(`overview.${status}`)
|
|
2345
|
+
$('overview-status-desc').textContent = t('overview.components', { n: online })
|
|
2346
|
+
for (const [name, ok] of Object.entries(checks)) {
|
|
2347
|
+
const item = document.querySelector(`[data-overview-link="${name}"]`)
|
|
2348
|
+
if (!item) continue
|
|
2349
|
+
item.classList.toggle('ok', ok)
|
|
2350
|
+
item.classList.toggle('off', !ok)
|
|
2351
|
+
const value = item.querySelector('b')
|
|
2352
|
+
if (value) value.textContent = ok ? t('overview.online') : t('overview.offlineShort')
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
const pending = [
|
|
2356
|
+
...state.approvals.map(a => ({ kind: 'approval', item: a })),
|
|
2357
|
+
...state.questions.map(q => ({ kind: 'question', item: q }))
|
|
2358
|
+
]
|
|
2359
|
+
$('overview-attention-count').textContent = pending.length ? t('overview.pendingCount', { n: pending.length }) : '—'
|
|
2360
|
+
$('overview-attention-list').innerHTML = pending.length ? pending.slice(0, 3).map(({ kind, item }) => {
|
|
2361
|
+
const title = titleOf(state.byId.get(item.sessionId))
|
|
2362
|
+
if (kind === 'approval') return `<div class="overview-attention-item" data-overview-approval="${esc(item.approvalId)}">
|
|
2363
|
+
<span class="overview-item-mark">⌁</span><span class="overview-item-copy"><span class="overview-item-title">${esc(item.toolName || t('tool.default'))}</span><span class="overview-item-desc">${esc(item.reason || t('pending.noReason'))} · ${esc(title)}</span></span>
|
|
2364
|
+
<span class="overview-item-actions"><button type="button" class="mini-btn" data-overview-approve="1">${t('pending.allow')}</button><button type="button" class="mini-btn" data-overview-approve="0">${t('pending.reject')}</button></span>
|
|
2365
|
+
</div>`
|
|
2366
|
+
return `<button type="button" class="overview-attention-item question" data-overview-question="${esc(item.rpcId)}">
|
|
2367
|
+
<span class="overview-item-mark">?</span><span class="overview-item-copy"><span class="overview-item-title">${esc(item.questions?.[0]?.question || t('notify.questionTitle'))}</span><span class="overview-item-desc">${esc(title)}</span></span><span class="overview-item-arrow">›</span>
|
|
2368
|
+
</button>`
|
|
2369
|
+
}).join('') : `<div class="overview-empty">${t('pending.empty')}</div>`
|
|
2370
|
+
$('overview-attention-list').querySelectorAll('[data-overview-approve]').forEach(btn => {
|
|
2371
|
+
btn.addEventListener('click', () => approveApproval(btn.closest('[data-overview-approval]')?.dataset.overviewApproval || '', btn.dataset.overviewApprove === '1'))
|
|
2372
|
+
})
|
|
2373
|
+
$('overview-attention-list').querySelectorAll('[data-overview-question]').forEach(btn => {
|
|
2374
|
+
btn.addEventListener('click', () => openQuestionModal(state.questions.find(q => q.rpcId === btn.dataset.overviewQuestion)))
|
|
2375
|
+
})
|
|
2376
|
+
|
|
2377
|
+
const running = state.sessions.filter(s => s.running).length
|
|
2378
|
+
const sessions = [...state.sessions].sort((a, b) => Number(b.running) - Number(a.running) || (new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0))).slice(0, 4)
|
|
2379
|
+
const primary = $('overview-primary-action')
|
|
2380
|
+
if (primary) {
|
|
2381
|
+
let action = 'new'
|
|
2382
|
+
let label = t('overview.action.newSession')
|
|
2383
|
+
let sessionId = ''
|
|
2384
|
+
if (!state.token) {
|
|
2385
|
+
action = 'settings'
|
|
2386
|
+
label = t('overview.action.connect')
|
|
2387
|
+
} else if (online > 0 && online < 4) {
|
|
2388
|
+
action = 'refresh'
|
|
2389
|
+
label = t('overview.action.refresh')
|
|
2390
|
+
} else if (pending.length) {
|
|
2391
|
+
action = 'attention'
|
|
2392
|
+
label = t('overview.action.attention')
|
|
2393
|
+
} else if (sessions.length) {
|
|
2394
|
+
action = 'session'
|
|
2395
|
+
sessionId = sessions[0].sessionId
|
|
2396
|
+
label = t('overview.action.openSession')
|
|
2397
|
+
}
|
|
2398
|
+
primary.textContent = label
|
|
2399
|
+
primary.dataset.overviewAction = action
|
|
2400
|
+
primary.dataset.overviewSession = sessionId
|
|
2401
|
+
primary.disabled = status === 'offline' && action === 'refresh'
|
|
2402
|
+
}
|
|
2403
|
+
$('overview-dsh-version').textContent = state.hostInfo?.version || '—'
|
|
2404
|
+
$('overview-gateway-version').textContent = checks.gateway ? t('overview.online') : t('overview.offlineShort')
|
|
2405
|
+
$('overview-active-sessions').textContent = String(running)
|
|
2406
|
+
$('overview-connection-mode').textContent = state.token ? t(state.streamMode === 'poll' ? 'overview.poll' : 'overview.ws') : '—'
|
|
2407
|
+
$('overview-active-count').textContent = running ? t('overview.activeCount', { n: running }) : ''
|
|
2408
|
+
$('overview-session-list').innerHTML = sessions.length ? sessions.map(s => `<button type="button" class="overview-session-item ${s.running ? 'running' : ''}" data-overview-session="${esc(s.sessionId)}">
|
|
2409
|
+
<span class="overview-item-mark">${s.running ? '●' : '○'}</span><span class="overview-item-copy"><span class="overview-item-title">${esc(titleOf(s))}</span><span class="overview-item-desc">${s.running ? esc(t('sessions.running')) + ' · ' : ''}${esc(fmtTime(s.updatedAt))}</span></span><span class="overview-item-arrow">›</span>
|
|
2410
|
+
</button>`).join('') : `<div class="overview-empty">${t('overview.noSession')}</div>`
|
|
2411
|
+
$('overview-session-list').querySelectorAll('[data-overview-session]').forEach(btn => btn.addEventListener('click', () => openSession(btn.dataset.overviewSession)))
|
|
2079
2412
|
}
|
|
2080
2413
|
|
|
2081
|
-
/* ---------------- 待办 ---------------- */
|
|
2082
2414
|
function renderPending() {
|
|
2083
2415
|
const list = $('pending-list')
|
|
2084
2416
|
const items = [
|
|
@@ -2113,6 +2445,7 @@ function renderPending() {
|
|
|
2113
2445
|
list.querySelectorAll('[data-question]').forEach(btn =>
|
|
2114
2446
|
btn.addEventListener('click', () => openQuestionModal(state.questions.find(q => q.rpcId === btn.dataset.question))))
|
|
2115
2447
|
updatePendingBadge()
|
|
2448
|
+
renderOverview()
|
|
2116
2449
|
}
|
|
2117
2450
|
|
|
2118
2451
|
async function approveApproval(id, allow) {
|
|
@@ -2306,7 +2639,7 @@ function renderFs(data) {
|
|
|
2306
2639
|
list.innerHTML = data.entries.map(e => {
|
|
2307
2640
|
const isDir = e.type === 'dir'
|
|
2308
2641
|
return `<div class="fs-row" data-name="${esc(e.name)}" data-type="${esc(e.type)}">
|
|
2309
|
-
<span class="fs-ico">${isDir
|
|
2642
|
+
<span class="fs-ico">${fsIconSvg(isDir)}</span>
|
|
2310
2643
|
<span class="fs-meta">
|
|
2311
2644
|
<span class="fs-name">${esc(e.name)}</span>
|
|
2312
2645
|
<span class="fs-sub">${isDir ? t('fs.dir') : fmtSize(e.size)} · ${fmtFullTime(e.mtimeMs)}</span>
|
|
@@ -2318,6 +2651,12 @@ function renderFs(data) {
|
|
|
2318
2651
|
row.addEventListener('click', () => fsOpenEntry(row.dataset.name, row.dataset.type)))
|
|
2319
2652
|
}
|
|
2320
2653
|
|
|
2654
|
+
function fsIconSvg(isDir) {
|
|
2655
|
+
return isDir
|
|
2656
|
+
? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.5 6.5h6l2 2H20a1 1 0 0 1 1 1v8.5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7.5a1 1 0 0 1 .5-1Z"/></svg>'
|
|
2657
|
+
: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3.5h8l4 4V20a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4.5a1 1 0 0 1 1-1Z"/><path d="M14 3.5v4h4M8 13h8M8 16h6"/></svg>'
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2321
2660
|
function fsOpenEntry(name, type) {
|
|
2322
2661
|
if (!name) return
|
|
2323
2662
|
const p = fsJoin(state.fs.path, name)
|
|
@@ -2744,6 +3083,7 @@ async function loadLocalVersion() {
|
|
|
2744
3083
|
* 公告只读取文本并用 textContent/转义后的换行渲染,不执行服务端下发的 HTML/脚本。
|
|
2745
3084
|
*/
|
|
2746
3085
|
const ANNOUNCEMENTS_KEY = 'seenAnnouncementsV1'
|
|
3086
|
+
const ANNOUNCEMENT_HISTORY_KEY = 'announcementHistoryV1'
|
|
2747
3087
|
function readSeenAnnouncements() {
|
|
2748
3088
|
try {
|
|
2749
3089
|
const value = JSON.parse(LS.get(ANNOUNCEMENTS_KEY, '{}'))
|
|
@@ -2761,6 +3101,40 @@ function markAnnouncementSeen(id) {
|
|
|
2761
3101
|
}
|
|
2762
3102
|
LS.set(ANNOUNCEMENTS_KEY, JSON.stringify(seen))
|
|
2763
3103
|
}
|
|
3104
|
+
function readAnnouncementHistory() {
|
|
3105
|
+
try {
|
|
3106
|
+
const value = JSON.parse(LS.get(ANNOUNCEMENT_HISTORY_KEY, '[]'))
|
|
3107
|
+
return Array.isArray(value) ? value.filter(item => item && typeof item.id === 'string') : []
|
|
3108
|
+
} catch { return [] }
|
|
3109
|
+
}
|
|
3110
|
+
function storeAnnouncementHistory(items) {
|
|
3111
|
+
const merged = new Map(readAnnouncementHistory().map(item => [item.id, item]))
|
|
3112
|
+
for (const item of items) if (item?.id) merged.set(item.id, item)
|
|
3113
|
+
const list = [...merged.values()].sort((a, b) => Number(b.publishedAt || 0) - Number(a.publishedAt || 0)).slice(0, 50)
|
|
3114
|
+
LS.set(ANNOUNCEMENT_HISTORY_KEY, JSON.stringify(list))
|
|
3115
|
+
return list
|
|
3116
|
+
}
|
|
3117
|
+
function renderAnnouncementHistory() {
|
|
3118
|
+
const box = $('announcement-history-list')
|
|
3119
|
+
if (!box) return
|
|
3120
|
+
const list = readAnnouncementHistory()
|
|
3121
|
+
if (!list.length) {
|
|
3122
|
+
box.innerHTML = `<div class="empty">${esc(t('announcement.historyEmpty'))}</div>`
|
|
3123
|
+
return
|
|
3124
|
+
}
|
|
3125
|
+
box.innerHTML = list.map(item => {
|
|
3126
|
+
const date = Number(item.publishedAt) > 0 ? fmtFullTime(item.publishedAt) : t('announcement.noDate')
|
|
3127
|
+
const action = item.actionUrl ? `<a class="announcement-action" href="${esc(item.actionUrl)}" target="_blank" rel="noopener">${esc(item.actionText || t('announcement.open'))}</a>` : ''
|
|
3128
|
+
return `<details class="announcement-history-item"><summary><span>${esc(item.title)}</span><small>${esc(date)}</small></summary><div class="announcement-history-content">${esc(item.content).replace(/\r?\n/g, '<br>')}${action}</div></details>`
|
|
3129
|
+
}).join('')
|
|
3130
|
+
}
|
|
3131
|
+
function openAnnouncementHistory() {
|
|
3132
|
+
renderAnnouncementHistory()
|
|
3133
|
+
$('modal-announcement-history')?.classList.remove('hidden')
|
|
3134
|
+
}
|
|
3135
|
+
function closeAnnouncementHistory() {
|
|
3136
|
+
$('modal-announcement-history')?.classList.add('hidden')
|
|
3137
|
+
}
|
|
2764
3138
|
function announcementVersionMatch(item) {
|
|
2765
3139
|
const min = String(item.minVersion || item.minAppVersion || '').trim()
|
|
2766
3140
|
const max = String(item.maxVersion || item.maxAppVersion || '').trim()
|
|
@@ -2829,8 +3203,10 @@ async function checkAnnouncements() {
|
|
|
2829
3203
|
if (raw.length > 512 * 1024) return false
|
|
2830
3204
|
const data = JSON.parse(raw)
|
|
2831
3205
|
const source = Array.isArray(data) ? data : (Array.isArray(data?.items) ? data.items : [data])
|
|
3206
|
+
const normalized = source.map(item => normalizeAnnouncement(item, base)).filter(Boolean)
|
|
3207
|
+
storeAnnouncementHistory(normalized)
|
|
2832
3208
|
const seen = readSeenAnnouncements()
|
|
2833
|
-
const items =
|
|
3209
|
+
const items = normalized.filter(item => !seen[item.id])
|
|
2834
3210
|
.sort((a, b) => b.publishedAt - a.publishedAt)
|
|
2835
3211
|
if (!items.length) return false
|
|
2836
3212
|
openAnnouncementModal(items[0])
|
|
@@ -3294,8 +3670,84 @@ function updateConn() {
|
|
|
3294
3670
|
}
|
|
3295
3671
|
|
|
3296
3672
|
function autosize(el) {
|
|
3673
|
+
// 全屏编辑时 textarea 由 flex 容器提供整块高度;普通的 120px 限高
|
|
3674
|
+
// 不能覆盖这里,否则输入超过约五行后会被重新压回小输入框。
|
|
3675
|
+
if (el?.id === 'composer-input' && $('composer-wrap')?.classList.contains('fs')) {
|
|
3676
|
+
el.style.height = '100%'
|
|
3677
|
+
updateComposerFullscreenButton()
|
|
3678
|
+
return
|
|
3679
|
+
}
|
|
3297
3680
|
el.style.height = 'auto'
|
|
3298
3681
|
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
|
|
3682
|
+
if (el.id === 'composer-input') updateComposerFullscreenButton()
|
|
3683
|
+
}
|
|
3684
|
+
|
|
3685
|
+
function updateComposerFullscreenButton() {
|
|
3686
|
+
const input = $('composer-input')
|
|
3687
|
+
const wrap = $('composer-wrap')
|
|
3688
|
+
const button = $('btn-fs-toggle')
|
|
3689
|
+
if (!input || !wrap || !button) return
|
|
3690
|
+
const active = wrap.classList.contains('fs')
|
|
3691
|
+
const shouldShow = active || input.scrollHeight > 120
|
|
3692
|
+
button.classList.toggle('hidden', !shouldShow)
|
|
3693
|
+
$('composer-input-wrap')?.classList.toggle('has-fs-btn', shouldShow)
|
|
3694
|
+
$('fs-ico-expand')?.classList.toggle('hidden', active)
|
|
3695
|
+
$('fs-ico-collapse')?.classList.toggle('hidden', !active)
|
|
3696
|
+
button.title = t(active ? 'composer.exitFullscreen' : 'composer.fullscreen')
|
|
3697
|
+
button.setAttribute('aria-label', t(active ? 'composer.exitFullscreen' : 'composer.fullscreen'))
|
|
3698
|
+
}
|
|
3699
|
+
|
|
3700
|
+
function setComposerFullscreen(on) {
|
|
3701
|
+
const wrap = $('composer-wrap')
|
|
3702
|
+
if (!wrap) return
|
|
3703
|
+
$('btn-stats')?.classList.toggle('hidden', !!on)
|
|
3704
|
+
$('btn-fs-send')?.classList.toggle('hidden', !on)
|
|
3705
|
+
if (on) {
|
|
3706
|
+
$('composer-image-menu')?.classList.add('hidden')
|
|
3707
|
+
$('btn-image')?.classList.remove('active')
|
|
3708
|
+
}
|
|
3709
|
+
wrap.classList.toggle('fs', !!on)
|
|
3710
|
+
document.body.classList.toggle('composer-fullscreen', !!on)
|
|
3711
|
+
if (on) {
|
|
3712
|
+
$('composer-input')?.style.removeProperty('height')
|
|
3713
|
+
} else {
|
|
3714
|
+
wrap.style.transform = ''
|
|
3715
|
+
wrap.classList.remove('dragging')
|
|
3716
|
+
autosize($('composer-input'))
|
|
3717
|
+
}
|
|
3718
|
+
updateComposerFullscreenButton()
|
|
3719
|
+
}
|
|
3720
|
+
|
|
3721
|
+
function bindComposerFullscreenGesture() {
|
|
3722
|
+
const handle = $('composer-fs-handle')
|
|
3723
|
+
if (!handle) return
|
|
3724
|
+
let startY = 0
|
|
3725
|
+
let tracking = false
|
|
3726
|
+
handle.addEventListener('touchstart', e => {
|
|
3727
|
+
if (!$('composer-wrap').classList.contains('fs') || e.touches.length !== 1) return
|
|
3728
|
+
tracking = true
|
|
3729
|
+
startY = e.touches[0].clientY
|
|
3730
|
+
}, { passive: true })
|
|
3731
|
+
handle.addEventListener('touchmove', e => {
|
|
3732
|
+
if (!tracking || e.touches.length !== 1) return
|
|
3733
|
+
const dy = e.touches[0].clientY - startY
|
|
3734
|
+
if (dy <= 0) return
|
|
3735
|
+
e.preventDefault()
|
|
3736
|
+
$('composer-wrap').classList.add('dragging')
|
|
3737
|
+
$('composer-wrap').style.transform = `translateY(${Math.min(dy, 180)}px)`
|
|
3738
|
+
}, { passive: false })
|
|
3739
|
+
const finish = () => {
|
|
3740
|
+
if (!tracking) return
|
|
3741
|
+
const wrap = $('composer-wrap')
|
|
3742
|
+
const transform = wrap.style.transform.match(/translateY\(([-\d.]+)px\)/)
|
|
3743
|
+
const dy = transform ? Number(transform[1]) : 0
|
|
3744
|
+
tracking = false
|
|
3745
|
+
wrap.classList.remove('dragging')
|
|
3746
|
+
if (dy > 60) setComposerFullscreen(false)
|
|
3747
|
+
else wrap.style.transform = ''
|
|
3748
|
+
}
|
|
3749
|
+
handle.addEventListener('touchend', finish, { passive: true })
|
|
3750
|
+
handle.addEventListener('touchcancel', finish, { passive: true })
|
|
3299
3751
|
}
|
|
3300
3752
|
|
|
3301
3753
|
/* ---------------- 初始化 ---------------- */
|
|
@@ -3547,6 +3999,19 @@ function bindUi() {
|
|
|
3547
3999
|
// 底部导航
|
|
3548
4000
|
document.querySelectorAll('.nav-btn').forEach(b =>
|
|
3549
4001
|
b.addEventListener('click', () => showView(b.dataset.view)))
|
|
4002
|
+
$('overview-primary-action').addEventListener('click', () => {
|
|
4003
|
+
const button = $('overview-primary-action')
|
|
4004
|
+
const action = button.dataset.overviewAction
|
|
4005
|
+
if (action === 'session' && button.dataset.overviewSession) return openSession(button.dataset.overviewSession)
|
|
4006
|
+
if (action === 'new') return newSession()
|
|
4007
|
+
if (action === 'settings') return showView('view-settings')
|
|
4008
|
+
if (action === 'refresh') return openStreams()
|
|
4009
|
+
const first = document.querySelector('.overview-attention-item')
|
|
4010
|
+
if (first) {
|
|
4011
|
+
first.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
4012
|
+
if (first.matches('button')) first.focus({ preventScroll: true })
|
|
4013
|
+
}
|
|
4014
|
+
})
|
|
3550
4015
|
// 会话列表点击
|
|
3551
4016
|
$('session-list').addEventListener('click', (e) => {
|
|
3552
4017
|
if (swipeSuppressClickUntil > Date.now()) { swipeSuppressClickUntil = 0; return }
|
|
@@ -3576,6 +4041,12 @@ function bindUi() {
|
|
|
3576
4041
|
renderWorkbench()
|
|
3577
4042
|
})
|
|
3578
4043
|
$('wb-panel').addEventListener('click', (e) => {
|
|
4044
|
+
const archive = e.target.closest('[data-archive-session]')
|
|
4045
|
+
if (archive) {
|
|
4046
|
+
e.stopPropagation()
|
|
4047
|
+
archiveSession(archive.dataset.archiveSession)
|
|
4048
|
+
return
|
|
4049
|
+
}
|
|
3579
4050
|
const newButton = e.target.closest('[data-wb-new]')
|
|
3580
4051
|
if (newButton) {
|
|
3581
4052
|
safeRpc('session.create', { workspaceId: newButton.dataset.wbNew }, t('home.createFailed')).then(async v => {
|
|
@@ -3634,7 +4105,28 @@ function bindUi() {
|
|
|
3634
4105
|
})
|
|
3635
4106
|
$('btn-cancel').addEventListener('click', cancelSession)
|
|
3636
4107
|
$('btn-send').addEventListener('click', sendMessage)
|
|
4108
|
+
$('btn-fs-send').addEventListener('click', sendMessage)
|
|
3637
4109
|
$('btn-plus').addEventListener('click', toggleComposerMenu)
|
|
4110
|
+
$('btn-image').addEventListener('click', toggleComposerImageMenu)
|
|
4111
|
+
$('composer-image-menu').addEventListener('click', (e) => {
|
|
4112
|
+
const option = e.target.closest('[data-image-source]')
|
|
4113
|
+
if (!option) return
|
|
4114
|
+
$('composer-image-menu').classList.add('hidden')
|
|
4115
|
+
$('btn-image').classList.remove('active')
|
|
4116
|
+
captureComposerImage(option.dataset.imageSource)
|
|
4117
|
+
})
|
|
4118
|
+
$('composer-attachments').addEventListener('click', (e) => {
|
|
4119
|
+
const button = e.target.closest('[data-remove-image]')
|
|
4120
|
+
if (button) removeComposerImage(button.dataset.removeImage)
|
|
4121
|
+
})
|
|
4122
|
+
$('composer-camera-input').addEventListener('change', (e) => { addComposerImages(e.target.files); e.target.value = '' })
|
|
4123
|
+
$('composer-gallery-input').addEventListener('change', (e) => { addComposerImages(e.target.files); e.target.value = '' })
|
|
4124
|
+
document.addEventListener('click', (e) => {
|
|
4125
|
+
if (!e.target.closest('#composer-image-menu, #btn-image')) {
|
|
4126
|
+
$('composer-image-menu')?.classList.add('hidden')
|
|
4127
|
+
$('btn-image')?.classList.remove('active')
|
|
4128
|
+
}
|
|
4129
|
+
})
|
|
3638
4130
|
$('composer-menu').addEventListener('click', async (e) => {
|
|
3639
4131
|
const chip = e.target.closest('[data-cmd]')
|
|
3640
4132
|
if (chip) {
|
|
@@ -3675,6 +4167,9 @@ function bindUi() {
|
|
|
3675
4167
|
$('btn-model-refresh').addEventListener('click', loadSessionModels)
|
|
3676
4168
|
const input = $('composer-input')
|
|
3677
4169
|
input.addEventListener('input', () => autosize(input))
|
|
4170
|
+
$('btn-fs-toggle').addEventListener('click', () => setComposerFullscreen(!$('composer-wrap').classList.contains('fs')))
|
|
4171
|
+
bindComposerFullscreenGesture()
|
|
4172
|
+
updateComposerFullscreenButton()
|
|
3678
4173
|
input.addEventListener('keydown', (e) => {
|
|
3679
4174
|
if (e.key !== 'Enter' || e.isComposing) return
|
|
3680
4175
|
if (isMobileDevice() && mobileEnterAction() !== 'send') return
|
|
@@ -3708,6 +4203,10 @@ function bindUi() {
|
|
|
3708
4203
|
$('modal-announcement').addEventListener('click', (e) => {
|
|
3709
4204
|
if (e.target === $('modal-announcement') && !state.announcement?.force) closeAnnouncement(false)
|
|
3710
4205
|
})
|
|
4206
|
+
$('announcement-history-close').addEventListener('click', closeAnnouncementHistory)
|
|
4207
|
+
$('modal-announcement-history').addEventListener('click', (e) => {
|
|
4208
|
+
if (e.target === $('modal-announcement-history')) closeAnnouncementHistory()
|
|
4209
|
+
})
|
|
3711
4210
|
// 设置
|
|
3712
4211
|
$('view-settings').addEventListener('click', (e) => {
|
|
3713
4212
|
const group = e.target.closest('[data-settings-group]')
|
|
@@ -3744,7 +4243,7 @@ function bindUi() {
|
|
|
3744
4243
|
$('btn-update-expand').addEventListener('click', toggleUpdateExpand)
|
|
3745
4244
|
$('btn-reset').addEventListener('click', () => {
|
|
3746
4245
|
if (!confirm(t('settings.confirmReset'))) return
|
|
3747
|
-
LS.del('token'); LS.del('notify'); LS.del('server'); LS.del('mobileEnterAction')
|
|
4246
|
+
LS.del('token'); LS.del('notify'); LS.del('server'); LS.del('mobileEnterAction'); LS.del(ANNOUNCEMENTS_KEY); LS.del(ANNOUNCEMENT_HISTORY_KEY)
|
|
3748
4247
|
if (bgBridge()?.saveBackgroundConfig) saveBgConfig(false)
|
|
3749
4248
|
location.reload()
|
|
3750
4249
|
})
|
|
@@ -3788,6 +4287,7 @@ function bindUi() {
|
|
|
3788
4287
|
LS.set('peakRemind', e.target.checked ? '1' : '0')
|
|
3789
4288
|
})
|
|
3790
4289
|
$('btn-test-notify').addEventListener('click', sendTestNotification)
|
|
4290
|
+
$('btn-announcement-history').addEventListener('click', openAnnouncementHistory)
|
|
3791
4291
|
// 已开启则启动时重新调度, 防止系统清理后丢失
|
|
3792
4292
|
if (peakRemindOn() && CAP?.isNativePlatform?.()) schedulePeakReminders()
|
|
3793
4293
|
applyBgConfigFromNative()
|
|
@@ -3883,10 +4383,11 @@ async function boot() {
|
|
|
3883
4383
|
bindNativeBack()
|
|
3884
4384
|
bindNativeLinks()
|
|
3885
4385
|
applyNativeInsets()
|
|
4386
|
+
showView('view-activity')
|
|
3886
4387
|
updateConn()
|
|
3887
4388
|
await loadLocalVersion()
|
|
3888
4389
|
if (!state.token) {
|
|
3889
|
-
showView('view-
|
|
4390
|
+
showView('view-activity')
|
|
3890
4391
|
$('token-desc').textContent = t('token.notSetHint')
|
|
3891
4392
|
} else {
|
|
3892
4393
|
// 多服务器: 启动时静默测速一次, 选最快的连接(同源页面也参与比较)
|