dsh-remote-plugin 0.6.23 → 0.6.25
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/apk/dsh-remote.apk +0 -0
- package/gateway-stats.cjs +106 -84
- package/gateway.cjs +78 -7
- package/package.json +1 -1
- package/public/app.js +169 -250
- package/public/desktop/desktop.css +7 -0
- package/public/desktop/desktop.html +27 -4
- package/public/desktop/desktop.js +145 -21
- package/public/genui.css +2 -0
- package/public/genui.js +144 -0
- package/public/index.html +13 -28
- package/public/md.js +16 -3
- package/public/styles.css +4 -11
- package/public/update.json +12 -12
- package/public/version.json +1 -1
|
@@ -393,6 +393,27 @@
|
|
|
393
393
|
</div>
|
|
394
394
|
</div>
|
|
395
395
|
|
|
396
|
+
<!-- 新建会话工作区模态 -->
|
|
397
|
+
<div id="modal-new-session" class="ds-modal hidden" role="dialog" aria-modal="true">
|
|
398
|
+
<div class="ds-modal-card">
|
|
399
|
+
<div class="ds-modal-title" data-i18n="ds.newSessionTitle">新建会话</div>
|
|
400
|
+
<div class="ds-modal-body">
|
|
401
|
+
<p class="ds-new-session-desc" data-i18n="ds.newSessionDesc">选择一个 DSH 工作区,新会话将在该工作区中创建。</p>
|
|
402
|
+
<label class="ds-new-session-label" for="new-session-workspace" data-i18n="ds.newSessionWorkspace">工作区</label>
|
|
403
|
+
<select id="new-session-workspace" class="ds-new-session-select"></select>
|
|
404
|
+
<div id="new-session-workspace-details" class="ds-new-session-details">
|
|
405
|
+
<span id="new-session-workspace-name" class="ds-new-session-name"></span>
|
|
406
|
+
<code id="new-session-workspace-path" class="ds-new-session-path"></code>
|
|
407
|
+
</div>
|
|
408
|
+
<div id="new-session-empty" class="ds-empty hidden" data-i18n="ds.newSessionNoWorkspaces">暂无可用工作区,请先创建一个工作区。</div>
|
|
409
|
+
</div>
|
|
410
|
+
<div class="ds-modal-actions">
|
|
411
|
+
<button id="new-session-cancel" class="ds-btn" data-i18n="ds.cancel">取消</button>
|
|
412
|
+
<button id="new-session-create" class="ds-btn primary" data-i18n="ds.newSessionCreate">创建</button>
|
|
413
|
+
</div>
|
|
414
|
+
</div>
|
|
415
|
+
</div>
|
|
416
|
+
|
|
396
417
|
<!-- 预设提示词管理模态 -->
|
|
397
418
|
<div id="modal-presets" class="ds-modal hidden" role="dialog" aria-modal="true">
|
|
398
419
|
<div class="ds-modal-card">
|
|
@@ -470,7 +491,7 @@
|
|
|
470
491
|
<script>
|
|
471
492
|
window.DESKTOP_STR = {
|
|
472
493
|
zh: {
|
|
473
|
-
'ds.repo': 'GitHub 仓库', 'ds.newSession': '+ 新会话', 'ds.newWorkspace': '新建工作区', 'ds.sessions': '会话', 'ds.overview': '主页', 'ds.overviewEyebrow': 'SYSTEM OVERVIEW', 'ds.overviewSubtitle': '实时状态与需要你处理的事项',
|
|
494
|
+
'ds.repo': 'GitHub 仓库', 'ds.newSession': '+ 新会话', 'ds.sessionUntitled': '新会话', 'ds.newWorkspace': '新建工作区', 'ds.sessions': '会话', 'ds.overview': '主页', 'ds.overviewEyebrow': 'SYSTEM OVERVIEW', 'ds.overviewSubtitle': '实时状态与需要你处理的事项',
|
|
474
495
|
'ds.systemPulse': '系统链路', 'ds.systemNominal': '系统链路正常', 'ds.systemDegraded': '部分链路需要关注', 'ds.systemOffline': '连接已断开', 'ds.checking': '检查中…', 'ds.components': '{n}/4 个模块在线', 'ds.live': 'LIVE', 'ds.allLinked': '全部链路', 'ds.offlineCore': 'OFFLINE',
|
|
475
496
|
'ds.gateway': '网关', 'ds.dshUpstream': 'DSH 上游', 'ds.mux': '实时 mux', 'ds.host': '实时 host', 'ds.online': '在线', 'ds.offlineShort': '离线',
|
|
476
497
|
'ds.attention': '需要你处理', 'ds.runtime': '运行指标', 'ds.recentActivity': '近期活动', 'ds.dshVersion': 'DSH 版本', 'ds.gatewayStatus': '网关状态', 'ds.activeSessions': '活跃会话', 'ds.connectionMode': '连接模式', 'ds.liveWs': '实时 WS', 'ds.poll': '降级轮询', 'ds.running': '运行中', 'ds.noReason': '无说明', 'ds.noActivity': '暂无后台活动', 'ds.noSessions': '暂无会话', 'ds.refreshOverview': '刷新总览', 'ds.pendingCount': '{n} 项待处理', 'ds.activeCount': '{n} 个活跃会话', 'ds.nothingPending': '暂无事项', 'ds.action.openSession': '打开最近会话', 'ds.action.newSession': '新建会话', 'ds.action.attention': '处理待办', 'ds.action.connect': '去设置连接', 'ds.action.refresh': '重新检查连接',
|
|
@@ -482,7 +503,7 @@
|
|
|
482
503
|
'ds.cmdFeedback': '/feedback 反馈当前会话', 'ds.cmdGoal': '/goal 设置/查看任务目标',
|
|
483
504
|
'ds.cmdPermission': '/permission 切换权限预设', 'ds.cmdPlan': '/plan 进入/退出计划模式',
|
|
484
505
|
'ds.fsUp': '上级', 'ds.fsRoot': '允许根目录', 'ds.fsNewWorkspace': '新建工作区', 'ds.fsRefresh': '刷新', 'ds.fsEmpty': '目录为空',
|
|
485
|
-
'ds.workspaceCreateTitle': '新建工作区', 'ds.workspaceCreateDesc': '将在当前文件目录下创建文件夹,并自动打开新会话。', 'ds.workspaceParent': '父目录', 'ds.workspaceNamePlaceholder': '例如:my-project', 'ds.workspaceCreate': '创建并打开', 'ds.cancel': '取消', 'ds.workspaceNameRequired': '请输入工作区名称', 'ds.workspaceExists': '该目录已存在', 'ds.workspaceInvalidName': '名称不能包含路径分隔符', 'ds.workspaceCreateFailed': '创建工作区失败', 'ds.workspaceCreated': '工作区已创建', 'ds.workspaceCreatedNoSession': '工作区已创建,但新会话未能打开',
|
|
506
|
+
'ds.workspaceCreateTitle': '新建工作区', 'ds.workspaceCreateDesc': '将在当前文件目录下创建文件夹,并自动打开新会话。', 'ds.workspaceParent': '父目录', 'ds.workspaceNamePlaceholder': '例如:my-project', 'ds.workspaceCreate': '创建并打开', 'ds.cancel': '取消', 'ds.workspaceNameRequired': '请输入工作区名称', 'ds.workspaceExists': '该目录已存在', 'ds.workspaceInvalidName': '名称不能包含路径分隔符', 'ds.workspaceCreateFailed': '创建工作区失败', 'ds.workspaceCreated': '工作区已创建', 'ds.workspaceCreatedNoSession': '工作区已创建,但新会话未能打开', 'ds.newSessionTitle': '新建会话', 'ds.newSessionDesc': '选择一个 DSH 工作区,新会话将在该工作区中创建。', 'ds.newSessionWorkspace': '工作区', 'ds.newSessionNoWorkspaces': '暂无可用工作区,请先创建一个工作区。', 'ds.newSessionCreate': '创建', 'ds.newSessionChooseWorkspace': '请选择一个工作区',
|
|
486
507
|
'ds.groupGeneral': '通用', 'ds.groupGeneralDesc': '工具调用、预设提示词',
|
|
487
508
|
'ds.groupServers': '服务器', 'ds.groupServersDesc': '服务器地址与令牌',
|
|
488
509
|
'ds.groupNotify': '通知', 'ds.groupNotifyDesc': '通知与提醒',
|
|
@@ -575,7 +596,7 @@
|
|
|
575
596
|
'menu.modelTitle': '模型切换', 'menu.effortTitle': '思考深度',
|
|
576
597
|
},
|
|
577
598
|
en: {
|
|
578
|
-
'ds.repo': 'GitHub repo', 'ds.newSession': '+ New session', 'ds.newWorkspace': 'New workspace', 'ds.sessions': 'Sessions', 'ds.overview': 'Home', 'ds.overviewEyebrow': 'SYSTEM OVERVIEW', 'ds.overviewSubtitle': 'Live status and items needing your attention',
|
|
599
|
+
'ds.repo': 'GitHub repo', 'ds.newSession': '+ New session', 'ds.sessionUntitled': 'New session', 'ds.newWorkspace': 'New workspace', 'ds.sessions': 'Sessions', 'ds.overview': 'Home', 'ds.overviewEyebrow': 'SYSTEM OVERVIEW', 'ds.overviewSubtitle': 'Live status and items needing your attention',
|
|
579
600
|
'ds.systemPulse': 'System pulse', 'ds.systemNominal': 'System nominal', 'ds.systemDegraded': 'Some links need attention', 'ds.systemOffline': 'Connection offline', 'ds.checking': 'Checking…', 'ds.components': '{n}/4 components online', 'ds.live': 'LIVE', 'ds.allLinked': 'ALL LINKED', 'ds.offlineCore': 'OFFLINE',
|
|
580
601
|
'ds.gateway': 'Gateway', 'ds.dshUpstream': 'DSH upstream', 'ds.mux': 'Live mux', 'ds.host': 'Live host', 'ds.online': 'Online', 'ds.offlineShort': 'Offline',
|
|
581
602
|
'ds.attention': 'Needs attention', 'ds.runtime': 'Runtime metrics', 'ds.recentActivity': 'Recent activity', 'ds.dshVersion': 'DSH version', 'ds.gatewayStatus': 'Gateway status', 'ds.activeSessions': 'Active sessions', 'ds.connectionMode': 'Connection mode', 'ds.liveWs': 'Live WS', 'ds.poll': 'Degraded polling', 'ds.running': 'Running', 'ds.noReason': 'No reason given', 'ds.noActivity': 'No background activity', 'ds.noSessions': 'No sessions yet', 'ds.refreshOverview': 'Refresh overview', 'ds.pendingCount': '{n} pending', 'ds.activeCount': '{n} active sessions', 'ds.nothingPending': 'Nothing pending', 'ds.action.openSession': 'Open recent session', 'ds.action.newSession': 'New session', 'ds.action.attention': 'Review pending', 'ds.action.connect': 'Set up connection', 'ds.action.refresh': 'Check connection',
|
|
@@ -587,7 +608,7 @@
|
|
|
587
608
|
'ds.cmdFeedback': '/feedback Feedback current session', 'ds.cmdGoal': '/goal Set/view task goal',
|
|
588
609
|
'ds.cmdPermission': '/permission Switch permission preset', 'ds.cmdPlan': '/plan Enter/exit plan mode',
|
|
589
610
|
'ds.fsUp': 'Up', 'ds.fsRoot': 'Allowed root', 'ds.fsNewWorkspace': 'New workspace', 'ds.fsRefresh': 'Refresh', 'ds.fsEmpty': 'Empty directory',
|
|
590
|
-
'ds.workspaceCreateTitle': 'New workspace', 'ds.workspaceCreateDesc': 'Create a folder in the current file directory and open a new session there.', 'ds.workspaceParent': 'Parent folder', 'ds.workspaceNamePlaceholder': 'For example: my-project', 'ds.workspaceCreate': 'Create & open', 'ds.cancel': 'Cancel', 'ds.workspaceNameRequired': 'Enter a workspace name', 'ds.workspaceExists': 'That folder already exists', 'ds.workspaceInvalidName': 'The name cannot contain path separators', 'ds.workspaceCreateFailed': 'Could not create workspace', 'ds.workspaceCreated': 'Workspace created', 'ds.workspaceCreatedNoSession': 'Workspace created, but the new session could not be opened',
|
|
611
|
+
'ds.workspaceCreateTitle': 'New workspace', 'ds.workspaceCreateDesc': 'Create a folder in the current file directory and open a new session there.', 'ds.workspaceParent': 'Parent folder', 'ds.workspaceNamePlaceholder': 'For example: my-project', 'ds.workspaceCreate': 'Create & open', 'ds.cancel': 'Cancel', 'ds.workspaceNameRequired': 'Enter a workspace name', 'ds.workspaceExists': 'That folder already exists', 'ds.workspaceInvalidName': 'The name cannot contain path separators', 'ds.workspaceCreateFailed': 'Could not create workspace', 'ds.workspaceCreated': 'Workspace created', 'ds.workspaceCreatedNoSession': 'Workspace created, but the new session could not be opened', 'ds.newSessionTitle': 'New session', 'ds.newSessionDesc': 'Choose a DSH workspace for the new session.', 'ds.newSessionWorkspace': 'Workspace', 'ds.newSessionNoWorkspaces': 'No workspaces are available. Create a workspace first.', 'ds.newSessionCreate': 'Create', 'ds.newSessionChooseWorkspace': 'Choose a workspace',
|
|
591
612
|
'ds.groupGeneral': 'General', 'ds.groupGeneralDesc': 'Tool calls, prompt presets',
|
|
592
613
|
'ds.groupServers': 'Servers', 'ds.groupServersDesc': 'Server address and token',
|
|
593
614
|
'ds.groupNotify': 'Notifications', 'ds.groupNotifyDesc': 'Notifications & reminders',
|
|
@@ -682,6 +703,8 @@
|
|
|
682
703
|
}
|
|
683
704
|
</script>
|
|
684
705
|
<script src="i18n.js"></script>
|
|
706
|
+
<link rel="stylesheet" href="../genui.css">
|
|
707
|
+
<script src="../genui.js"></script>
|
|
685
708
|
<script src="../md.js"></script>
|
|
686
709
|
<script src="../vendor/gsap/gsap.min.js"></script>
|
|
687
710
|
<script src="../motion.js"></script>
|
|
@@ -78,6 +78,7 @@ const state = {
|
|
|
78
78
|
approvals: [],
|
|
79
79
|
questions: [],
|
|
80
80
|
queues: {},
|
|
81
|
+
sessionActivity: new Set(),
|
|
81
82
|
queueSteering: {},
|
|
82
83
|
compactions: {},
|
|
83
84
|
pendingCommands: {},
|
|
@@ -95,6 +96,7 @@ const state = {
|
|
|
95
96
|
pollSeq: { mux: 0, host: 0 },
|
|
96
97
|
fs: { path: null, initial: null, loaded: false, roots: [], rootIndex: 0 },
|
|
97
98
|
models: { loaded: false, loading: false, groups: [], current: null, failures: [] },
|
|
99
|
+
workspaces: [],
|
|
98
100
|
wb: { bound: false, path: '', title: '', expanded: false, projects: null, open: null, apiMissing: false },
|
|
99
101
|
archivedIds: [],
|
|
100
102
|
view: 'sessions',
|
|
@@ -1353,6 +1355,10 @@ document.addEventListener('visibilitychange', () => {
|
|
|
1353
1355
|
function onMuxFrame(full) {
|
|
1354
1356
|
const f = full.payload
|
|
1355
1357
|
if (!f) return
|
|
1358
|
+
if (f.type === 'session/reasoning') {
|
|
1359
|
+
if (f.sessionId === state.current) { applyReasoningBaseline(f.partialReasoning); scheduleReasoningRender() }
|
|
1360
|
+
return
|
|
1361
|
+
}
|
|
1356
1362
|
if (f.type === 'session/event') return onSessionEvent(f.sessionId, f.event)
|
|
1357
1363
|
if (f.type === 'approval/requested') {
|
|
1358
1364
|
state.approvals = state.approvals.filter(a => a.approvalId !== f.approvalId)
|
|
@@ -1530,7 +1536,17 @@ function resyncAfterStreamOpen() {
|
|
|
1530
1536
|
void refreshSessions().then(() => resyncCurrentSession())
|
|
1531
1537
|
}
|
|
1532
1538
|
function proj(s, key, d) { return s?.projections?.values?.[key] ?? d }
|
|
1533
|
-
function
|
|
1539
|
+
function sessionTitleValue(s) {
|
|
1540
|
+
const value = proj(s, 'title', '')
|
|
1541
|
+
return value == null ? '' : String(value).trim()
|
|
1542
|
+
}
|
|
1543
|
+
function hasSessionTitle(s) { return !!sessionTitleValue(s) }
|
|
1544
|
+
function titleOf(s) { return sessionTitleValue(s) || (s?.sessionId ? t('ds.sessionUntitled') : t('ds.sessions')) }
|
|
1545
|
+
function sessionLabelOf(s) {
|
|
1546
|
+
const title = titleOf(s)
|
|
1547
|
+
if (!s?.sessionId || hasSessionTitle(s)) return title
|
|
1548
|
+
return `${title} · ${String(s.sessionId).slice(-8)}`
|
|
1549
|
+
}
|
|
1534
1550
|
function isTopLevelSession(session) {
|
|
1535
1551
|
return !!session && !session.parentSessionId && session.origin !== 'subagent'
|
|
1536
1552
|
}
|
|
@@ -1660,7 +1676,7 @@ function renderSessions() {
|
|
|
1660
1676
|
const renderSession = s => {
|
|
1661
1677
|
const workspace = sessionWorkspaceLabel(s)
|
|
1662
1678
|
const workspaceName = workspaceDisplayName(workspace)
|
|
1663
|
-
const title =
|
|
1679
|
+
const title = sessionLabelOf(s)
|
|
1664
1680
|
return `<button class="ds-session-item ${state.current === s.sessionId ? 'current' : ''}" data-id="${esc(s.sessionId)}">
|
|
1665
1681
|
<span class="ds-session-title">${esc(title)}</span>
|
|
1666
1682
|
<span class="ds-session-workspace" title="${esc(workspace)}">⌂ ${esc(workspaceName)}</span>
|
|
@@ -1717,7 +1733,31 @@ function renderSessions() {
|
|
|
1717
1733
|
renderWorkbench()
|
|
1718
1734
|
}
|
|
1719
1735
|
|
|
1736
|
+
const emptySessionCleanup = new Set()
|
|
1737
|
+
async function archiveEmptySessionOnLeave(sessionId) {
|
|
1738
|
+
const base = state.server
|
|
1739
|
+
const protectedSession = () => state.server !== base || state.current !== sessionId
|
|
1740
|
+
|| !state.byId.has(sessionId) || state.byId.get(sessionId)?.running
|
|
1741
|
+
|| state.sessionActivity?.has(sessionId) || state.pendingPrompts?.has(sessionId)
|
|
1742
|
+
|| (state.queues[sessionId] || []).length > 0
|
|
1743
|
+
|| (state.composerImages || []).length > 0 || !!$('composer')?.value?.trim()
|
|
1744
|
+
if (!sessionId || emptySessionCleanup.has(sessionId) || protectedSession()) return false
|
|
1745
|
+
emptySessionCleanup.add(sessionId)
|
|
1746
|
+
try {
|
|
1747
|
+
const history = await rpc('session.history', { sessionId, maxMessages: 1 })
|
|
1748
|
+
if (protectedSession() || !Array.isArray(history?.events) || history.events.some(item => !['permission/preset', 'sandbox/mode', 'approval/policy'].includes(item?.event?.type || item?.type))
|
|
1749
|
+
|| history.hasMore || (history.partialReasoning || []).length) return false
|
|
1750
|
+
const result = await rpc('workspace.archiveSession', { sessionId })
|
|
1751
|
+
if (!Array.isArray(result?.archivedSessionIds) || !result.archivedSessionIds.includes(sessionId)) return false
|
|
1752
|
+
if (state.server !== base) return false
|
|
1753
|
+
await refreshSessions()
|
|
1754
|
+
return true
|
|
1755
|
+
} catch { return false } // 离线、历史未知或归档失败时保留会话。
|
|
1756
|
+
finally { emptySessionCleanup.delete(sessionId) }
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1720
1759
|
async function openSession(id) {
|
|
1760
|
+
if (state.current && state.current !== id) await archiveEmptySessionOnLeave(state.current)
|
|
1721
1761
|
state.current = id
|
|
1722
1762
|
setSessionRecovery('loading')
|
|
1723
1763
|
state.history = emptyDesktopHistory()
|
|
@@ -1734,7 +1774,10 @@ async function openSession(id) {
|
|
|
1734
1774
|
void refreshCompactionStatus(id)
|
|
1735
1775
|
await loadHistory()
|
|
1736
1776
|
}
|
|
1737
|
-
function closeSession() {
|
|
1777
|
+
async function closeSession() {
|
|
1778
|
+
const sessionId = state.current
|
|
1779
|
+
if (sessionId) await archiveEmptySessionOnLeave(sessionId)
|
|
1780
|
+
if (state.current !== sessionId) return
|
|
1738
1781
|
state.current = null
|
|
1739
1782
|
setSessionRecovery('idle')
|
|
1740
1783
|
state.history = emptyDesktopHistory()
|
|
@@ -1749,17 +1792,22 @@ function closeSession() {
|
|
|
1749
1792
|
async function loadHistory() {
|
|
1750
1793
|
const id = state.current
|
|
1751
1794
|
if (!id || state.history.loading) return
|
|
1795
|
+
const history = state.history
|
|
1796
|
+
const reasoningVersion = history.reasoningVersion || 0
|
|
1752
1797
|
state.history.loading = true
|
|
1753
1798
|
setSessionRecovery('loading')
|
|
1754
1799
|
let v
|
|
1755
1800
|
try { v = await rpc('session.history', { sessionId: id, maxMessages: 60 }) }
|
|
1756
1801
|
catch (e) {
|
|
1802
|
+
if (state.current !== id || state.history !== history) return
|
|
1757
1803
|
state.history.loading = false
|
|
1758
1804
|
if (e.message === 'AUTH') return
|
|
1759
1805
|
setSessionRecovery('error', e.message)
|
|
1760
1806
|
$('history').innerHTML = `<div class="ds-empty">${e.message}</div>`
|
|
1761
1807
|
return
|
|
1762
1808
|
}
|
|
1809
|
+
if (state.current !== id || state.history !== history) return
|
|
1810
|
+
const liveReasoning = (history.reasoningVersion || 0) !== reasoningVersion ? new Map(history.partialReasoning) : null
|
|
1763
1811
|
hydrateSessionProjections(id, v.projections)
|
|
1764
1812
|
for (const entry of v.events || []) {
|
|
1765
1813
|
const ev = entry?.event
|
|
@@ -1772,6 +1820,8 @@ async function loadHistory() {
|
|
|
1772
1820
|
state.history.visible.push({ seq, event: ev })
|
|
1773
1821
|
}
|
|
1774
1822
|
state.history.visible.sort((a, b) => a.seq - b.seq)
|
|
1823
|
+
if (liveReasoning) history.partialReasoning = liveReasoning
|
|
1824
|
+
else applyReasoningBaseline(v.partialReasoning)
|
|
1775
1825
|
state.history.hasMore = !!v.hasMore
|
|
1776
1826
|
state.history.loading = false
|
|
1777
1827
|
setSessionRecovery('ready')
|
|
@@ -1806,6 +1856,12 @@ function shouldShowEvent(type, event) {
|
|
|
1806
1856
|
return true
|
|
1807
1857
|
}
|
|
1808
1858
|
function reasoningStreamKey(data, index) { return `${data?.turn ?? '?'}:${data?.step ?? '?'}:${index ?? '?'}` }
|
|
1859
|
+
function applyReasoningBaseline(items) {
|
|
1860
|
+
if (!Array.isArray(items)) return
|
|
1861
|
+
state.history.reasoningVersion = (state.history.reasoningVersion || 0) + 1
|
|
1862
|
+
state.history.partialReasoning.clear()
|
|
1863
|
+
for (const item of items) state.history.partialReasoning.set(reasoningStreamKey(item, item.index), item)
|
|
1864
|
+
}
|
|
1809
1865
|
function applyReasoningStreamEvent(event) {
|
|
1810
1866
|
const h = state.history
|
|
1811
1867
|
const data = event?.data || {}
|
|
@@ -1831,7 +1887,7 @@ function applyReasoningStreamEvent(event) {
|
|
|
1831
1887
|
item.text += Array.isArray(data.texts) ? data.texts.join('') : String(data.text || '')
|
|
1832
1888
|
h.partialReasoning.set(key, item)
|
|
1833
1889
|
changed = true
|
|
1834
|
-
} else if (event?.type === 'assistant/message') {
|
|
1890
|
+
} else if (event?.type === 'assistant/message' || event?.type === 'assistant/attempt') {
|
|
1835
1891
|
for (const [key, item] of h.partialReasoning) {
|
|
1836
1892
|
if (item.turn === data.turn && item.step === data.step) { h.partialReasoning.delete(key); changed = true }
|
|
1837
1893
|
}
|
|
@@ -1857,6 +1913,7 @@ function safeJson(v) { try { return JSON.stringify(v, null, 2) } catch { return
|
|
|
1857
1913
|
function blockHtml(b) {
|
|
1858
1914
|
if (!b) return ''
|
|
1859
1915
|
if (b.type === 'text') return `<div class="md">${window.mdToHtml ? window.mdToHtml(b.text ?? '') : esc(b.text ?? '')}</div>`
|
|
1916
|
+
if (b.type === 'file') return `<div class="ds-tool">📎 ${esc(b.attachment?.name || b.name || 'file')} <small>${esc(b.attachment?.bytes ?? b.bytes ?? '')} bytes</small></div>`
|
|
1860
1917
|
if (b.type === 'thinking' || b.type === 'reasoning') return `<details><summary>${esc(t('block.thinking'))}</summary><div style="opacity:.82">${esc(b.text ?? b.content ?? '')}</div></details>`
|
|
1861
1918
|
if (b.type === 'tool-call') return `<div>🔧 ${esc(b.name || '')}</div>`
|
|
1862
1919
|
if (b.type === 'tool-result') return `<div>📦</div>`
|
|
@@ -2122,6 +2179,7 @@ async function sendMessage() {
|
|
|
2122
2179
|
const input = $('composer')
|
|
2123
2180
|
const text = input.value.trim()
|
|
2124
2181
|
if (!text || !state.current) return
|
|
2182
|
+
state.sessionActivity.add(state.current)
|
|
2125
2183
|
if (await runSlashCommand(text)) { input.value = ''; return }
|
|
2126
2184
|
input.value = ''
|
|
2127
2185
|
setSessionRecovery('resuming')
|
|
@@ -2146,7 +2204,7 @@ function renameSession(sessionId = state.current) {
|
|
|
2146
2204
|
const session = state.byId.get(sessionId)
|
|
2147
2205
|
if (!session) return
|
|
2148
2206
|
renamePendingSessionId = sessionId
|
|
2149
|
-
$('rename-session-input').value =
|
|
2207
|
+
$('rename-session-input').value = sessionTitleValue(session)
|
|
2150
2208
|
$('modal-rename').classList.remove('hidden')
|
|
2151
2209
|
setTimeout(() => { $('rename-session-input').focus(); $('rename-session-input').select() }, 40)
|
|
2152
2210
|
}
|
|
@@ -2480,9 +2538,19 @@ async function createWorkspace() {
|
|
|
2480
2538
|
}
|
|
2481
2539
|
closeWorkspaceModal()
|
|
2482
2540
|
await loadFs(parent || null, true)
|
|
2483
|
-
|
|
2541
|
+
let workspace = null
|
|
2542
|
+
try {
|
|
2543
|
+
const created = await rpc('workspace.create', { path: data.path })
|
|
2544
|
+
workspace = created?.workspace || null
|
|
2545
|
+
} catch {}
|
|
2546
|
+
if (workspace?.workspaceId) {
|
|
2547
|
+
state.workspaces = [...state.workspaces.filter(item => item.workspaceId !== workspace.workspaceId), workspace]
|
|
2548
|
+
}
|
|
2549
|
+
const sessionPayload = workspace?.workspaceId ? { workspaceId: workspace.workspaceId } : { cwd: data.path }
|
|
2550
|
+
const v = await safeRpc('session.create', sessionPayload, t('ds.toastOpFailed'))
|
|
2484
2551
|
await refreshSessions()
|
|
2485
2552
|
if (v?.sessionId) {
|
|
2553
|
+
if (workspace?.workspaceId) LS.set('lastNewSessionWorkspaceV1', workspace.workspaceId)
|
|
2486
2554
|
toast(t('ds.workspaceCreated'), 'ok')
|
|
2487
2555
|
openSession(v.sessionId)
|
|
2488
2556
|
} else {
|
|
@@ -2566,6 +2634,22 @@ function wbBaseName(p) {
|
|
|
2566
2634
|
const value = String(p || '').replace(/[\\/]+$/, '')
|
|
2567
2635
|
return value.split(/[\\/]/).pop() || value
|
|
2568
2636
|
}
|
|
2637
|
+
function workspaceItems() {
|
|
2638
|
+
return (state.workspaces || []).filter(w => w && typeof w.workspaceId === 'string' && w.workspaceId && typeof w.path === 'string' && w.path)
|
|
2639
|
+
}
|
|
2640
|
+
function workspaceById(workspaceId) {
|
|
2641
|
+
return workspaceItems().find(w => w.workspaceId === workspaceId) || null
|
|
2642
|
+
}
|
|
2643
|
+
function workspaceName(workspace) {
|
|
2644
|
+
return String(workspace?.title || wbBaseName(workspace?.path) || workspace?.path || '').trim()
|
|
2645
|
+
}
|
|
2646
|
+
function workspaceOptionLabel(workspace) {
|
|
2647
|
+
const name = workspaceName(workspace)
|
|
2648
|
+
return workspace.path && workspace.path !== name ? `${name} — ${workspace.path}` : name
|
|
2649
|
+
}
|
|
2650
|
+
function workspaceOptionsHtml(selected = '') {
|
|
2651
|
+
return workspaceItems().map(workspace => `<option value="${esc(workspace.workspaceId)}"${workspace.workspaceId === selected ? ' selected' : ''}>${esc(workspaceOptionLabel(workspace))}</option>`).join('')
|
|
2652
|
+
}
|
|
2569
2653
|
function wbStrictInside(pathValue, rootValue) {
|
|
2570
2654
|
const pathKey = wbPathKey(pathValue)
|
|
2571
2655
|
const rootKey = wbPathKey(rootValue)
|
|
@@ -2668,6 +2752,7 @@ async function refreshWorkbench({ silent = false } = {}) {
|
|
|
2668
2752
|
}
|
|
2669
2753
|
const wl = await safeRpc('workspace.list', {}, '')
|
|
2670
2754
|
state.archivedIds = wl && Array.isArray(wl.archivedSessionIds) ? wl.archivedSessionIds : []
|
|
2755
|
+
state.workspaces = wl && Array.isArray(wl.items) ? wl.items.slice() : []
|
|
2671
2756
|
if (!wb) { renderWorkbench(); renderSessions(); return }
|
|
2672
2757
|
if (!wb.bound) {
|
|
2673
2758
|
state.wb = { bound: false, path: '', title: '', expanded: false, projects: null, open: null, apiMissing: false }
|
|
@@ -2696,7 +2781,11 @@ async function refreshWorkbench({ silent = false } = {}) {
|
|
|
2696
2781
|
if (have.has(wbPathKey(projectPath))) continue
|
|
2697
2782
|
try {
|
|
2698
2783
|
const created = await rpc('workspace.create', { path: projectPath })
|
|
2699
|
-
if (created?.workspace) {
|
|
2784
|
+
if (created?.workspace) {
|
|
2785
|
+
items.push(created.workspace)
|
|
2786
|
+
state.workspaces.push(created.workspace)
|
|
2787
|
+
have.add(wbPathKey(projectPath))
|
|
2788
|
+
}
|
|
2700
2789
|
} catch {}
|
|
2701
2790
|
}
|
|
2702
2791
|
}
|
|
@@ -2744,7 +2833,7 @@ function renderWorkbench() {
|
|
|
2744
2833
|
</button>
|
|
2745
2834
|
<div class="ds-wb-project-body ${open ? '' : 'hidden'}">
|
|
2746
2835
|
<button type="button" class="ds-mini-btn ds-wb-new-session" data-wb-new="${esc(id)}">+ ${esc(t('wb.newSession'))}</button>
|
|
2747
|
-
${sessions.length ? sessions.map(s => `<button type="button" class="ds-wb-session ${state.current === s.sessionId ? 'current' : ''}" data-wb-session="${esc(s.sessionId)}" data-motion-key="${esc(s.sessionId)}"><span class="ds-wb-session-drag-handle" data-reorder-handle aria-hidden="true">⠿</span><span class="ds-wb-session-dot ${s.running ? 'running' : ''}"></span><span class="ds-wb-session-title">${esc(
|
|
2836
|
+
${sessions.length ? sessions.map(s => `<button type="button" class="ds-wb-session ${state.current === s.sessionId ? 'current' : ''}" data-wb-session="${esc(s.sessionId)}" data-motion-key="${esc(s.sessionId)}"><span class="ds-wb-session-drag-handle" data-reorder-handle aria-hidden="true">⠿</span><span class="ds-wb-session-dot ${s.running ? 'running' : ''}"></span><span class="ds-wb-session-title">${esc(sessionLabelOf(s))}</span></button>`).join('') : `<div class="ds-wb-session-empty">${esc(t('wb.noSessions'))}</div>`}
|
|
2748
2837
|
</div>
|
|
2749
2838
|
</div>`
|
|
2750
2839
|
}).join('') : `<div class="ds-wb-empty">${esc(t('wb.noProjects'))}</div>`
|
|
@@ -2774,6 +2863,46 @@ function renderWorkbench() {
|
|
|
2774
2863
|
panel.querySelectorAll('[data-wb-session]').forEach(button => button.addEventListener('click', () => openSession(button.dataset.wbSession)))
|
|
2775
2864
|
panel.querySelectorAll('[data-wb-unbind-panel]').forEach(button => button.addEventListener('click', unbindWorkbench))
|
|
2776
2865
|
}
|
|
2866
|
+
function renderNewSessionWorkspace(preferredId = '') {
|
|
2867
|
+
const items = workspaceItems()
|
|
2868
|
+
const select = $('new-session-workspace')
|
|
2869
|
+
if (!select) return
|
|
2870
|
+
const current = workspaceById(preferredId || select.value)?.workspaceId || items[0]?.workspaceId || ''
|
|
2871
|
+
select.innerHTML = workspaceOptionsHtml(current)
|
|
2872
|
+
select.disabled = !items.length
|
|
2873
|
+
const workspace = workspaceById(select.value)
|
|
2874
|
+
$('new-session-workspace-name').textContent = workspace ? workspaceName(workspace) : ''
|
|
2875
|
+
$('new-session-workspace-path').textContent = workspace?.path || ''
|
|
2876
|
+
$('new-session-empty').classList.toggle('hidden', !!items.length)
|
|
2877
|
+
$('new-session-create').disabled = !workspace
|
|
2878
|
+
}
|
|
2879
|
+
async function openNewSessionModal() {
|
|
2880
|
+
if (!state.token) return showView('view-settings')
|
|
2881
|
+
if (!workspaceItems().length) await refreshWorkbench({ silent: true })
|
|
2882
|
+
renderNewSessionWorkspace(LS.get('lastNewSessionWorkspaceV1', ''))
|
|
2883
|
+
$('modal-new-session').classList.remove('hidden')
|
|
2884
|
+
setTimeout(() => $('new-session-workspace')?.focus(), 50)
|
|
2885
|
+
}
|
|
2886
|
+
function closeNewSessionModal() { $('modal-new-session').classList.add('hidden') }
|
|
2887
|
+
async function createSessionInWorkspace() {
|
|
2888
|
+
if (createSessionInWorkspace.busy) return
|
|
2889
|
+
const workspaceId = $('new-session-workspace').value
|
|
2890
|
+
if (!workspaceById(workspaceId)) return toast(t('ds.newSessionChooseWorkspace'), 'err')
|
|
2891
|
+
createSessionInWorkspace.busy = true
|
|
2892
|
+
const button = $('new-session-create')
|
|
2893
|
+
button.disabled = true
|
|
2894
|
+
try {
|
|
2895
|
+
const value = await safeRpc('session.create', { workspaceId }, '')
|
|
2896
|
+
if (!value?.sessionId) return
|
|
2897
|
+
LS.set('lastNewSessionWorkspaceV1', workspaceId)
|
|
2898
|
+
closeNewSessionModal()
|
|
2899
|
+
await refreshSessions()
|
|
2900
|
+
openSession(value.sessionId)
|
|
2901
|
+
} finally {
|
|
2902
|
+
createSessionInWorkspace.busy = false
|
|
2903
|
+
button.disabled = false
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2777
2906
|
const wbFs = { path: null, initial: null }
|
|
2778
2907
|
function openWorkbenchModal() {
|
|
2779
2908
|
$('modal-workbench').classList.remove('hidden')
|
|
@@ -2975,7 +3104,7 @@ function renderOverviewDesktop() {
|
|
|
2975
3104
|
]
|
|
2976
3105
|
$('ds-overview-attention-count').textContent = pending.length ? t('ds.pendingCount', { n: pending.length }) : '—'
|
|
2977
3106
|
$('ds-overview-attention-list').innerHTML = pending.length ? pending.slice(0, 4).map(({ kind, item }) => {
|
|
2978
|
-
const title =
|
|
3107
|
+
const title = sessionLabelOf(state.byId.get(item.sessionId))
|
|
2979
3108
|
if (kind === 'approval') return `<div class="ds-overview-attention-item" data-ds-overview-approval="${esc(item.approvalId)}">
|
|
2980
3109
|
<span class="ds-overview-mark">⌁</span><span class="ds-overview-copy"><span class="ds-overview-item-title">${esc(item.toolName || t('ds.toolDefault'))}</span><span class="ds-overview-item-desc">${esc(desktopApprovalDetail(item))} · ${esc(title)}</span></span>
|
|
2981
3110
|
<span class="ds-overview-actions"><button class="ds-btn allow" data-ds-overview-approve="1">${t('ds.allow')}</button><button class="ds-btn reject" data-ds-overview-approve="0">${t('ds.reject')}</button></span>
|
|
@@ -3019,7 +3148,7 @@ function renderOverviewDesktop() {
|
|
|
3019
3148
|
$('ds-overview-connection-mode').textContent = state.token ? t(state.streamMode === 'poll' ? 'ds.poll' : 'ds.liveWs') : '—'
|
|
3020
3149
|
$('ds-overview-active-count').textContent = running ? t('ds.activeCount', { n: running }) : ''
|
|
3021
3150
|
$('ds-overview-session-list').innerHTML = sessions.length ? sessions.map(s => `<button type="button" class="ds-overview-session-item ${s.running ? 'running' : ''}" data-ds-overview-session="${esc(s.sessionId)}">
|
|
3022
|
-
<span class="ds-overview-mark">${s.running ? '●' : '○'}</span><span class="ds-overview-copy"><span class="ds-overview-item-title">${esc(
|
|
3151
|
+
<span class="ds-overview-mark">${s.running ? '●' : '○'}</span><span class="ds-overview-copy"><span class="ds-overview-item-title">${esc(sessionLabelOf(s))}</span><span class="ds-overview-item-desc">${s.running ? esc(t('ds.running')) + ' · ' : ''}${esc(fmtTime(sessionSortTime(s)))}</span></span><span class="ds-overview-arrow">›</span>
|
|
3023
3152
|
</button>`).join('') : `<div class="ds-overview-empty">${t('ds.noSessions')}</div>`
|
|
3024
3153
|
$('ds-overview-session-list').querySelectorAll('[data-ds-overview-session]').forEach(btn => btn.addEventListener('click', () => openSession(btn.dataset.dsOverviewSession)))
|
|
3025
3154
|
}
|
|
@@ -3039,6 +3168,7 @@ function setMobileSidebar(open) {
|
|
|
3039
3168
|
}
|
|
3040
3169
|
|
|
3041
3170
|
function showView(id) {
|
|
3171
|
+
if (state.view === 'view-chat' && id !== 'view-chat' && state.current) void archiveEmptySessionOnLeave(state.current)
|
|
3042
3172
|
state.view = id
|
|
3043
3173
|
for (const v of ['view-overview', 'view-sessions', 'view-chat', 'view-files', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
|
|
3044
3174
|
document.querySelectorAll('.ds-nav-item').forEach(b => b.classList.toggle('active', b.dataset.view === id))
|
|
@@ -3128,17 +3258,7 @@ function updateConn() {
|
|
|
3128
3258
|
|
|
3129
3259
|
/* ---------------- 初始化 ---------------- */
|
|
3130
3260
|
function bindUi() {
|
|
3131
|
-
$('btn-new-session').addEventListener('click',
|
|
3132
|
-
let payload = {}
|
|
3133
|
-
// 与移动端保持一致:新会话继承 DSH 当前工作目录;查询失败时兼容回退。
|
|
3134
|
-
try {
|
|
3135
|
-
const host = await rpc('host.describe', {}, 5000)
|
|
3136
|
-
const cwd = typeof host?.cwd === 'string' ? host.cwd.trim() : ''
|
|
3137
|
-
if (cwd) payload = { cwd }
|
|
3138
|
-
} catch {}
|
|
3139
|
-
const v = await safeRpc('session.create', payload, '')
|
|
3140
|
-
if (v?.sessionId) { await refreshSessions(); openSession(v.sessionId) }
|
|
3141
|
-
})
|
|
3261
|
+
$('btn-new-session').addEventListener('click', openNewSessionModal)
|
|
3142
3262
|
$('btn-new-workspace').addEventListener('click', openWorkspaceModal)
|
|
3143
3263
|
$('session-sort')?.addEventListener('change', (e) => {
|
|
3144
3264
|
state.sessionSort = e.target.value === 'workspace' ? 'workspace' : 'time'
|
|
@@ -3261,6 +3381,10 @@ function bindUi() {
|
|
|
3261
3381
|
$('workspace-create').addEventListener('click', createWorkspace)
|
|
3262
3382
|
$('workspace-name').addEventListener('keydown', (e) => { if (e.key === 'Enter') createWorkspace() })
|
|
3263
3383
|
$('modal-workspace').addEventListener('click', (e) => { if (e.target === $('modal-workspace')) closeWorkspaceModal() })
|
|
3384
|
+
$('new-session-workspace').addEventListener('change', () => renderNewSessionWorkspace())
|
|
3385
|
+
$('new-session-cancel').addEventListener('click', closeNewSessionModal)
|
|
3386
|
+
$('new-session-create').addEventListener('click', createSessionInWorkspace)
|
|
3387
|
+
$('modal-new-session').addEventListener('click', (e) => { if (e.target === $('modal-new-session')) closeNewSessionModal() })
|
|
3264
3388
|
$('modal-notes').addEventListener('click', (e) => { if (e.target === $('modal-notes')) closeNotesModal() })
|
|
3265
3389
|
// 反馈
|
|
3266
3390
|
$('btn-feedback').addEventListener('click', (e) => { e.stopPropagation(); toggleFeedbackMenu() })
|
package/public/genui.css
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
.genui{margin:14px 0;padding:14px;border:1px solid #8884;border-radius:12px;line-height:1.65;overflow:hidden;color:inherit;background:#88808;font-size:14px}
|
|
2
|
+
.genui *{box-sizing:border-box}.genui h1,.genui h2,.genui h3,.genui h4,.genui p{margin:0 0 10px}.genui-caption{font-size:11px;opacity:.65;margin-bottom:12px}.genui-row{display:flex;flex-wrap:wrap;gap:12px}.genui-row>*{flex:1;min-width:120px}.genui-col{display:grid;gap:12px}.genui-grid{display:grid;grid-template-columns:repeat(var(--genui-cols,2),minmax(0,1fr));gap:12px}.genui-card{border:1px solid #8884;border-radius:8px;padding:12px;min-width:0}.genui-card>h4{font-size:16px}.genui-stat{display:flex;flex-direction:column;min-width:0;padding:8px 0}.genui-stat strong{font-size:28px;line-height:1.25;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.genui-stat small,.genui-stat span{opacity:.75}.genui-badge{display:inline-block;padding:2px 9px;border:1px solid #8885;border-radius:20px}.genui progress{display:block;width:100%;accent-color:#2563eb;height:10px;margin:7px 0}.genui-spacer{height:12px}.genui-callout{border-left:3px solid #2563eb;padding:10px 12px;background:#2563eb12}.genui-chart{margin:10px 0}.genui-chart figcaption{font-weight:600}.genui-chart svg{display:block;width:100%;height:auto;max-height:520px}.genui-chart svg text{fill:currentColor;font:11px system-ui}.genui-gridline{stroke:currentColor;opacity:.15;fill:none}.genui-legend{display:flex;flex-wrap:wrap;gap:7px 14px;font-size:12px}.genui-legend i{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:5px}.genui-scroll{overflow:auto;max-height:420px}.genui table{width:100%;border-collapse:collapse;font-size:12px}.genui td,.genui th{text-align:left;padding:7px;border-bottom:1px solid #8884;white-space:nowrap}.genui-source{margin-top:12px}.genui summary{cursor:pointer;font-size:12px}.genui pre{max-height:320px;overflow:auto;white-space:pre-wrap;word-break:break-word}.genui-unsupported{border-left:2px solid #d97706;padding:8px;margin:8px 0}.genui-html{display:block;width:100%;height:440px;border:1px solid #8884;border-radius:7px;background:white}.genui dl{display:grid;grid-template-columns:auto 1fr;gap:6px 12px}.genui dd{margin:0}.genui li{margin:4px 0}@media(max-width:520px){.genui{padding:10px}.genui-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.genui-stat strong{font-size:23px}.genui-html{height:380px}}
|
package/public/genui.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/* DSH Remote: dependency-free, read-only dsh-ui subset. Never executes model code. */
|
|
2
|
+
(function (root, factory) {
|
|
3
|
+
if (typeof module === 'object' && module.exports) module.exports = factory()
|
|
4
|
+
else root.DshGenUi = factory()
|
|
5
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
6
|
+
'use strict'
|
|
7
|
+
const esc = value => String(value == null ? '' : value).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
|
|
8
|
+
const palette = ['#2563eb', '#059669', '#d97706', '#db2777', '#7c3aed', '#0891b2']
|
|
9
|
+
const array = value => Array.isArray(value) ? value : []
|
|
10
|
+
const number = value => typeof value === 'number' && Number.isFinite(value) && Math.abs(value) <= 1e12
|
|
11
|
+
const color = (value, i) => /^#[0-9a-f]{3}(?:[0-9a-f]{3})?$/i.test(value || '') ? value : palette[i % palette.length]
|
|
12
|
+
const raw = value => '<pre><code>' + esc(typeof value === 'string' ? value : JSON.stringify(value, null, 2)) + '</code></pre>'
|
|
13
|
+
const details = value => '<details class="genui-source"><summary>JSON / 源码</summary>' + raw(value) + '</details>'
|
|
14
|
+
function unsupported(node, reason) {
|
|
15
|
+
return '<div class="genui-unsupported">暂不支持 / Unsupported: ' + esc(reason || node?.type || 'spec') + details(node) + '</div>'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function chart(node) {
|
|
19
|
+
const kind = node.kind || 'bars'
|
|
20
|
+
if (!['bars', 'line', 'donut'].includes(kind) || node.stacked || node.filter || node.sortField) return unsupported(node, 'chart options')
|
|
21
|
+
const series = array(node.series).length ? node.series : [{ label: node.title || '', data: node.data }]
|
|
22
|
+
if (series.length > 6 || (kind === 'donut' && series.length !== 1)) return unsupported(node, 'chart series')
|
|
23
|
+
if (series.some(s => !s || !Array.isArray(s.data) || !s.data.length || s.data.length > 100 || s.data.some(d => !d || !number(d.value)))) return unsupported(node, 'chart data')
|
|
24
|
+
const data = series[0].data
|
|
25
|
+
if (series.some(s => s.data.length !== data.length || s.data.some((d, i) => d.label !== data[i].label))) return unsupported(node, 'unaligned series')
|
|
26
|
+
const colors = series.map((s, i) => color(s.color || array(node.palette)[i], i))
|
|
27
|
+
const table = '<div class="genui-scroll"><table><thead><tr><th></th>' + series.map(s => '<th>' + esc(s.label || 'Value') + '</th>').join('') + '</tr></thead><tbody>' + data.map((d, i) => '<tr><th>' + esc(d.label) + '</th>' + series.map(s => '<td>' + esc(s.data[i].value) + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>'
|
|
28
|
+
let svg = ''
|
|
29
|
+
if (kind === 'donut') {
|
|
30
|
+
const total = data.reduce((sum, d) => sum + d.value, 0)
|
|
31
|
+
if (data.some(d => d.value < 0) || total <= 0) return unsupported(node, 'donut requires non-negative values and positive total')
|
|
32
|
+
let offset = 0
|
|
33
|
+
svg = data.map((d, i) => {
|
|
34
|
+
const fraction = d.value / total * 100
|
|
35
|
+
const segment = '<circle cx="160" cy="120" r="75" fill="none" stroke="' + color(d.color || array(node.palette)[i], i) + '" stroke-width="34" pathLength="100" stroke-dasharray="' + fraction + ' ' + (100 - fraction) + '" stroke-dashoffset="' + (-offset) + '" transform="rotate(-90 160 120)"><title>' + esc(d.label) + ': ' + esc(d.value) + ' (' + fraction.toFixed(1) + '%)</title></circle>'
|
|
36
|
+
offset += fraction
|
|
37
|
+
return segment
|
|
38
|
+
}).join('') + '<text x="160" y="124" text-anchor="middle" fill="currentColor">' + esc(total) + '</text>'
|
|
39
|
+
svg = '<svg viewBox="0 0 320 240" role="img" aria-label="' + esc(node.title || 'Donut chart') + '">' + svg + '</svg>'
|
|
40
|
+
svg += '<div class="genui-legend">' + data.map((d, i) => '<span><i style="background:' + color(d.color || array(node.palette)[i], i) + '"></i>' + esc(d.label) + ' · ' + (d.value / total * 100).toFixed(1) + '%</span>').join('') + '</div>'
|
|
41
|
+
} else {
|
|
42
|
+
const values = series.flatMap(s => s.data.map(d => d.value))
|
|
43
|
+
let min = Math.min(0, ...values), max = Math.max(0, ...values)
|
|
44
|
+
if (min === max) max = min + 1
|
|
45
|
+
const horizontal = kind === 'bars' && node.horizontal === true
|
|
46
|
+
const width = 560, height = horizontal ? Math.max(240, data.length * 35 + 50) : 270
|
|
47
|
+
const left = horizontal ? 110 : 64, right = 20, top = 20, bottom = 45
|
|
48
|
+
const w = width - left - right, h = height - top - bottom
|
|
49
|
+
const scale = value => horizontal ? left + (value - min) / (max - min) * w : top + (max - value) / (max - min) * h
|
|
50
|
+
for (let i = 0; i <= 4; i++) {
|
|
51
|
+
const value = min + (max - min) * i / 4, p = scale(value)
|
|
52
|
+
svg += horizontal
|
|
53
|
+
? '<path d="M' + p + ' ' + top + 'V' + (top + h) + '" class="genui-gridline"/><text x="' + p + '" y="' + (height - 12) + '" text-anchor="middle">' + esc(Number(value.toPrecision(4))) + '</text>'
|
|
54
|
+
: '<path d="M' + left + ' ' + p + 'H' + (left + w) + '" class="genui-gridline"/><text x="' + (left - 7) + '" y="' + (p + 4) + '" text-anchor="end">' + esc(Number(value.toPrecision(4))) + '</text>'
|
|
55
|
+
}
|
|
56
|
+
const step = (horizontal ? h : w) / data.length
|
|
57
|
+
series.forEach((s, si) => {
|
|
58
|
+
const points = []
|
|
59
|
+
s.data.forEach((d, i) => {
|
|
60
|
+
const center = (horizontal ? top : left) + step * (i + 0.5), pos = scale(d.value)
|
|
61
|
+
const hint = '<title>' + esc((s.label ? s.label + ' · ' : '') + d.label + ': ' + d.value) + '</title>'
|
|
62
|
+
if (kind === 'line') {
|
|
63
|
+
points.push(center + ',' + pos)
|
|
64
|
+
svg += '<circle cx="' + center + '" cy="' + pos + '" r="3" fill="' + colors[si] + '">' + hint + '</circle>'
|
|
65
|
+
} else {
|
|
66
|
+
const size = step * 0.7 / series.length, lane = center - step * 0.35 + si * size
|
|
67
|
+
const zero = scale(0)
|
|
68
|
+
svg += '<rect x="' + (horizontal ? Math.min(zero, pos) : lane) + '" y="' + (horizontal ? lane : Math.min(zero, pos)) + '" width="' + (horizontal ? Math.abs(zero - pos) : size) + '" height="' + (horizontal ? size : Math.abs(zero - pos)) + '" fill="' + (series.length === 1 ? color(d.color || array(node.palette)[i], i) : colors[si]) + '">' + hint + '</rect>'
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
if (kind === 'line') svg += '<polyline points="' + points.join(' ') + '" fill="none" stroke="' + colors[si] + '" stroke-width="2.5"/>'
|
|
72
|
+
})
|
|
73
|
+
data.forEach((d, i) => {
|
|
74
|
+
if (!horizontal && i % Math.ceil(data.length / 8) !== 0) return
|
|
75
|
+
const text = String(d.label || '')
|
|
76
|
+
svg += '<text x="' + (horizontal ? left - 6 : left + step * (i + 0.5)) + '" y="' + (horizontal ? top + step * (i + 0.5) + 4 : height - 21) + '" text-anchor="' + (horizontal ? 'end' : 'middle') + '">' + esc(text.length > 12 ? text.slice(0, 11) + '…' : text) + '</text>'
|
|
77
|
+
})
|
|
78
|
+
svg = '<svg viewBox="0 0 ' + width + ' ' + height + '" role="img" aria-label="' + esc(node.title || kind + ' chart') + '">' + svg + '</svg>'
|
|
79
|
+
if (series.length > 1) svg += '<div class="genui-legend">' + series.map((s, i) => '<span><i style="background:' + colors[i] + '"></i>' + esc(s.label) + '</span>').join('') + '</div>'
|
|
80
|
+
}
|
|
81
|
+
return '<figure class="genui-chart">' + (node.title ? '<figcaption>' + esc(node.title) + '</figcaption>' : '') + svg + '<details><summary>数据 / Data</summary>' + table + '</details></figure>'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function render(spec) {
|
|
85
|
+
if (!spec || typeof spec !== 'object') throw Error('Invalid spec')
|
|
86
|
+
let count = 0
|
|
87
|
+
function nodes(items, depth) { return array(items).map(n => node(n, depth)).join('') }
|
|
88
|
+
function node(n, depth) {
|
|
89
|
+
if (++count > 240 || depth > 12) throw Error('Component limit')
|
|
90
|
+
if (!n || typeof n !== 'object' || Array.isArray(n)) return unsupported(n)
|
|
91
|
+
const type = n.type
|
|
92
|
+
// Reject data-transforming options rather than silently displaying different data.
|
|
93
|
+
if (n.filter || n.sortField) return unsupported(n, type + ' filter/sort')
|
|
94
|
+
if ((type === 'table' && (array(n.rows).length > 200 || array(n.columns).length > 30)) ||
|
|
95
|
+
(['list', 'timeline', 'steps', 'keyvalue'].includes(type) && array(n.items || n.steps || n.pairs).length > 100)) return unsupported(n, type + ' size limit')
|
|
96
|
+
if (['row', 'col', 'grid', 'card'].includes(type)) {
|
|
97
|
+
const cols = Math.max(1, Math.min(4, Math.floor(Number(n.cols) || 2)))
|
|
98
|
+
return '<section class="genui-' + type + '"' + (type === 'grid' ? ' style="--genui-cols:' + cols + '"' : '') + '>' + (n.title ? '<h4>' + esc(n.title) + '</h4>' : '') + nodes(n.items, depth + 1) + '</section>'
|
|
99
|
+
}
|
|
100
|
+
if (type === 'text') { const tag = ['h1', 'h2', 'h3'].includes(n.size) ? n.size : 'p'; return '<' + tag + '>' + esc(n.content) + '</' + tag + '>' }
|
|
101
|
+
if (type === 'hero') return '<div class="genui-stat"><small>' + esc(n.label) + '</small><strong>' + esc(n.value) + '</strong><h4>' + esc(n.title) + '</h4><span>' + esc(n.subtitle) + '</span><span>' + esc(n.delta) + '</span></div>'
|
|
102
|
+
if (type === 'stat') return '<div class="genui-stat"><small>' + esc(n.label) + '</small><strong>' + esc(n.value) + '</strong><span>' + esc(n.delta) + '</span></div>'
|
|
103
|
+
if (type === 'badge') return '<span class="genui-badge">' + esc(n.label) + '</span>'
|
|
104
|
+
if (type === 'divider') return '<hr>'
|
|
105
|
+
if (type === 'spacer') return '<div class="genui-spacer"></div>'
|
|
106
|
+
if (type === 'progress' && !number(n.value)) return unsupported(n, 'progress value')
|
|
107
|
+
if (type === 'progress') return '<div>' + esc(n.label) + '<progress max="100" value="' + Math.max(0, Math.min(100, Number(n.value) || 0)) + '"></progress><small>' + esc(n.valueLabel || String(n.value || 0) + '%') + '</small>' + (number(n.target) ? '<small> · 目标 / Target: ' + esc(n.target) + '%</small>' : '') + '</div>'
|
|
108
|
+
if (type === 'callout') return '<aside class="genui-callout"><strong>' + esc(n.title) + '</strong><p>' + esc(n.content) + '</p></aside>'
|
|
109
|
+
if (type === 'code' || type === 'json') return raw(type === 'code' ? n.code : n.value)
|
|
110
|
+
if (type === 'list' || type === 'timeline' || type === 'steps') return '<ul>' + array(n.items || n.steps).slice(0, 100).map(v => '<li>' + (v && typeof v === 'object' ? (v.type ? node(v, depth + 1) : '<strong>' + esc(v.title) + '</strong> ' + esc(v.desc) + ' ' + esc(v.time)) : esc(v)) + '</li>').join('') + '</ul>'
|
|
111
|
+
if (type === 'keyvalue') return '<dl>' + array(n.pairs).slice(0, 100).map(v => '<dt>' + esc(v?.key) + '</dt><dd>' + esc(v?.value) + '</dd>').join('') + '</dl>'
|
|
112
|
+
if (type === 'table') return '<div class="genui-scroll"><table><thead><tr>' + array(n.columns).slice(0, 30).map(v => '<th>' + esc(v) + '</th>').join('') + '</tr></thead><tbody>' + array(n.rows).slice(0, 200).map(row => '<tr>' + array(row).slice(0, 30).map(v => '<td>' + esc(v) + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>'
|
|
113
|
+
if (type === 'chart') return chart(n)
|
|
114
|
+
if (type === 'echart' && !n.option && ['bar', 'line', 'pie'].includes(n.preset)) return '<small>ECharts 基础数据预览 / Basic preview</small>' + chart({ ...n, type: 'chart', kind: { bar: 'bars', line: 'line', pie: 'donut' }[n.preset] })
|
|
115
|
+
if (type === 'accordion' || type === 'tabs') return array(n.tabs || n.items).slice(0, 30).map(v => '<details><summary>' + esc(v?.label || v?.title) + '</summary>' + nodes(v?.items, depth + 1) + '</details>').join('')
|
|
116
|
+
return unsupported(n)
|
|
117
|
+
}
|
|
118
|
+
const body = Array.isArray(spec) ? nodes(spec, 0) : spec.type ? node(spec, 0) : nodes(spec.items, 0)
|
|
119
|
+
if (!body) throw Error('No components')
|
|
120
|
+
return '<section class="genui"><div class="genui-caption">DSH UI · 只读预览 / Read-only</div>' + (spec.title ? '<h3>' + esc(spec.title) + '</h3>' : '') + body + details(spec) + '</section>'
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function html(source) {
|
|
124
|
+
if (typeof document === 'undefined' || String(source).length > 200000) return raw(source)
|
|
125
|
+
// Template contents are inert: source images cannot start fetching during sanitization.
|
|
126
|
+
const template = document.createElement('template')
|
|
127
|
+
template.innerHTML = String(source)
|
|
128
|
+
const allowed = new Set('title style div span p br hr h1 h2 h3 h4 h5 h6 strong em b i u s small sub sup pre code blockquote ul ol li dl dt dd table thead tbody tfoot tr td th caption colgroup col section article header footer main aside figure figcaption details summary progress'.split(' '))
|
|
129
|
+
function clean(parent) {
|
|
130
|
+
for (const child of Array.from(parent.children)) {
|
|
131
|
+
if (!allowed.has(child.localName)) { child.remove(); continue }
|
|
132
|
+
for (const attr of Array.from(child.attributes)) {
|
|
133
|
+
if (!['style', 'class', 'colspan', 'rowspan', 'value', 'max'].includes(attr.name)) child.removeAttribute(attr.name)
|
|
134
|
+
}
|
|
135
|
+
clean(child)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
clean(template.content)
|
|
139
|
+
const policy = "default-src 'none'; script-src 'none'; style-src 'unsafe-inline'; img-src 'none'; connect-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'"
|
|
140
|
+
const srcDoc = '<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="' + policy + '"><meta name="viewport" content="width=device-width,initial-scale=1"><style>body{font:14px/1.6 system-ui;margin:16px;overflow-wrap:anywhere}table{border-collapse:collapse}td,th{padding:6px;border:1px solid #ccc}pre{white-space:pre-wrap}*{max-width:100%;box-sizing:border-box}</style></head><body>' + template.innerHTML + '</body></html>'
|
|
141
|
+
return '<section class="genui"><div class="genui-caption">HTML · 只读预览(脚本及外部资源已禁用)</div><iframe class="genui-html" sandbox="" referrerpolicy="no-referrer" title="HTML preview" srcdoc="' + esc(srcDoc) + '"></iframe></section>'
|
|
142
|
+
}
|
|
143
|
+
return { render, html }
|
|
144
|
+
})
|