fix(v0.5.1): 适配rc.5并修正控制室与更新提醒

This commit is contained in:
Aisland
2026-10-31 17:05:17 +08:00
parent 56ffe0cd1c
commit f98246ef0c
25 changed files with 703 additions and 237 deletions
+18 -2
View File
@@ -1,6 +1,22 @@
/** 更新公告正文;公开发布状态以 GitHub Release 为准。 */
export const CHANGELOG_VERSION = 'v0.5.0'
export const CHANGELOG_V030 = `更新公告 · v0.5.0
export const CHANGELOG_VERSION = 'v0.5.1'
export const CHANGELOG_V030 = `更新公告 · v0.5.1
🔧 DSH 0.2.1-rc.5 兼容与更新提醒修复
【加载修复】将 rc.5 兼容目标加入精确版本声明,处理宿主因版本声明不匹配而跳过工作台加载的问题;不通配放开未来版本。
【更新提醒】修复测试版版本号比较出错、把旧正式版当成新版本的问题。设置与控制室共用版本排序规则,读取缓存和显示卡片时重新确认目标更高且未忽略;检查失败或无更新时清掉旧提示,不引导降级。
【控制室显示】会话停止统一显示中性的“本轮已结束”,不再把失败或停止当作成功;正在运行优先于旧结束标记。卡片摘要只显示可读的助手答复,过滤注入上下文与仅供模型的替换副本;无答复时留空。
【兼容与验收】仅列已测 DSH 0.2.0-rc.2 / 0.2.1-rc.3 / 0.2.1-rc.5。rc.5 已有隔离网页检查;用户在官方 Windows Desktop 安装候选后确认工作台恢复、更新误报消失。正式包另做独立安装与自动回归,不将候选页面证据扩大为正式包全功能验收;旧 0.1.x、未测宿主及 macOS 不作兼容保证。
【保留边界】沿用 0.5.0 的网页安全隔离、接口身份校验与项目存储方式。新版输入框已有 /命令标签时,标注追加可能退回剪贴板;此项跨版本坐标适配待后续处理。本版不包含旧会话模型请求 HTTP 400 的修复。
───────
此前版本说明(v0.5.0)
🛡️ 新版宿主兼容、安全隔离与侧栏修复
+28 -53
View File
@@ -5,9 +5,10 @@ import { isAbs, joinPath, parentPathOf, basenameOf } from './pathutil'
import { splitStore, SplitWorkspace, setSplitT, setSplitEnv, type LayoutSpec, type SplitPane, type ConsoleCardData } from './split'
import { appendHostInput } from './hostInput'
import { worktableUpgrade } from './hostTransport'
import { LOCAL_VERSION, cmpVer, newerUpdateInfo, type UpdateInfo } from './updateCheck'
import { WidgetMountRegistry, newWidgetId, widgetManifestPath, type WidgetBinding } from './widgetMount'
import { currentSessionOf, openHostSession, sendHostSession, createSessionSnapshotAdapter, childSessionIdsOf, pendingAckOf, sessionRuntimeMs } from './sessionCompat'
import { cleanPreviewText, previewFromEvents, readSessionPreview, presetApiOf, modelApiOf, createHostSession, blankSessionNeedsWorkspace } from './sessionDetails'
import { currentSessionOf, openHostSession, sendHostSession, createSessionSnapshotAdapter, childSessionIdsOf, pendingAckOf, sessionRuntimeMs, consoleStatusOf } from './sessionCompat'
import { previewFromEvents, previewFromNodes, readSessionPreview, presetApiOf, modelApiOf, createHostSession, blankSessionNeedsWorkspace } from './sessionDetails'
import { photoStore, kindOf } from './photoStore'
import { DEFAULT_BG_SVG, b64ToBlob } from './defaultBg'
import { WAVE_BG_B64 } from './waveBg'
@@ -29,8 +30,6 @@ type OrderBy = 'manual' | 'recent'
type DockMode = 'footer' | 'float'
// ── 更新检查(客户端直连 GitHub Releases API,只读 GET;失败静默)──
declare const __WT_VERSION__: string
const LOCAL_VERSION = typeof __WT_VERSION__ === 'undefined' ? 'dev' : __WT_VERSION__
const UPDATE_REPO = 'Aisland-SJL/dsh-worktable'
const { command: UPGRADE_CMD, prompt: UPGRADE_AI, desktop: DESKTOP_HOST } = worktableUpgrade()
// 更新提示图标(手绘 SVG,避免 emoji 跨平台渲染差异)
@@ -48,17 +47,6 @@ const ICON_SPARK = (
<path d="M12.6 11.2l.5 1.3 1.3.5-1.3.5-.5 1.3-.5-1.3-1.3-.5 1.3-.5z" fill="currentColor" />
</svg>
)
type UpdateInfo = { latest: string; notes: string; url: string }
function cmpVer(a: string, b: string): number {
const pa = a.split('.').map(Number)
const pb = b.split('.').map(Number)
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const x = pa[i] ?? 0
const y = pb[i] ?? 0
if (x !== y) return x - y
}
return 0
}
async function copyText(text: string): Promise<boolean> {
try {
if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true }
@@ -618,27 +606,13 @@ function schedulePreviewSweep() {
previewTimer = window.setTimeout(() => { previewTimer = null; sweepPreviews() }, 6000)
}
/** 从会话快照节点里提取最近一条文本(纯读内存镜像,零 Token;代码清洗后无则 '') */
/** 从宿主内存镜像提取最近助手文本;无可读答复则留空,不回退注入/推理/工具内容。 */
function lastTextOf(sid: string): string {
try {
const binding = sessionBridge?.sessions?.binding?.(sid)
if (binding?.eventSource?.getSnapshot) return previewFromEvents(binding.eventSource.getSnapshot().entries)
const face = binding?.session?.getSnapshot?.()
const nodes: any[] = face?.nodes ?? []
for (let i = nodes.length - 1; i >= 0; i--) {
const n = nodes[i]
const blocks = Array.isArray(n?.blocks) ? n.blocks : (Array.isArray(n?.content) ? n.content : [])
let raw = ''
for (const b of blocks) {
const s = typeof b?.text === 'string' ? b.text.trim() : ''
if (!s) continue
if (b?.type === 'text') { raw = s; break }
if (!raw) raw = s
}
if (!raw) continue
const clean = cleanPreviewText(raw)
if (clean.length >= 8) return clean.slice(0, 220)
}
return previewFromNodes(face?.nodes)
} catch {}
return ''
}
@@ -759,7 +733,7 @@ function buildWindowTaskText(projectId: string, projectName: string, windowLabel
? '项目文件夹:' + folder + '(本项目所有产出文件一律放进这个文件夹;不要写到别的默认位置)。'
: '本项目暂未设置专属文件夹:产出文件先向用户确认存放位置,不要随便写。'
const lines = [
'【工作台自定义窗口任务】',
'【工作台】',
'以下内容由 dsh-worktable(工作台)插件' + (mode === 'new' ? '自动发送' : '发送') + ':用户想把「' + projectName + '」项目中的「' + win + '」窗口打造成他想要的内容。',
'1. dsh-worktable 是侧边栏底部的自建「工作台」插件(官方文档没有它的说明,不了解之处可直接向用户提问)。',
'2. 用户需求:' + requirement,
@@ -921,7 +895,7 @@ function buildMountContent(folder: string, d: any): any {
/** 每会话最近观察到的待决身份;同为 need 的问题替换也必须重新点亮。 */
const notifyStateSeenRef: { current: Record<string, string | null> } = { current: {} }
/** 每会话最近观察到的「已完成」布尔值:新一次完成时清除旧 done ack(控制室绿光重新点亮) */
/** 每会话最近观察到的结束边沿:新一轮结束清除旧 done ack;结束不表示成功。 */
const doneSeenRef: { current: Record<string, boolean> } = { current: {} }
/** 清除某会话的 ack 记录(状态转移时调用,保证新问题不会被旧确认压住) */
function clearNotifyAck(sid: string) {
@@ -934,11 +908,12 @@ function clearNotifyAck(sid: string) {
} catch {}
}
/** 会话状态 → 提醒类型:待决(黄) 优先于 完成(绿)——等待用户判断时 pendingInteraction 与
/** 会话状态 → 提醒类型:待决(黄) 优先于 本轮结束(中性)——等待用户判断时 pendingInteraction 与
* running 会同时为真,原生 UI 以黄点优先,镜像必须一致 */
function sessionNotifyState(entry: any): 'done' | 'need' | null {
if (!entry) return null
if (entry.pendingInteraction != null) return 'need'
if (entry.running === true) return null
if (entry.completed === true) return 'done'
return null
}
@@ -1062,7 +1037,8 @@ function WorktableSection(props: any) {
const [addOpen, setAddOpen] = useState(false)
const [viewOptionsOpen, setViewOptionsOpen] = useState(false)
// 更新检查:徽标 / 更新卡 / 版本行共用;节流一天一次,忽略按版本号存 localStorage
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
const [rawUpdateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
const updateInfo = newerUpdateInfo(rawUpdateInfo)
const [updateCheckOn, setUpdateCheckOn] = useState<boolean>(() => localStorage.getItem('dsh.worktable.updateCheck.v1') !== '0')
const [updateCopied, setUpdateCopied] = useState(false)
const [updateStatus, setUpdateStatus] = useState<'idle' | 'checking' | 'uptodate' | 'failed'>('idle')
@@ -1071,9 +1047,12 @@ function WorktableSection(props: any) {
useEffect(() => () => { updateAliveRef.current = false }, [])
const checkUpdates = useCallback(async (force = false) => {
if (updateCheckingRef.current) return // 防重入:并发点击只保留一个 in-flight
const last = Number(localStorage.getItem('dsh.worktable.lastUpdateCheck.v1') ?? '0')
if (!force && Date.now() - last < 24 * 3600 * 1000) return // 自动检查节流;手动「立即检查」绕过节流
let last = 0
try { last = Number(localStorage.getItem('dsh.worktable.lastUpdateCheck.v1') ?? '0') } catch {}
const age = Date.now() - last
if (!force && Number.isFinite(last) && last > 0 && age >= 0 && age < 24 * 3600 * 1000) return // 自动检查节流;手动「立即检查」绕过节流
updateCheckingRef.current = true
setUpdateInfo(null) // Every new result replaces the old card, including failure/no update.
setUpdateStatus('checking')
try {
let d: { tag_name?: string; body?: string; html_url?: string } | null = null
@@ -1089,11 +1068,12 @@ function WorktableSection(props: any) {
}
if (!updateAliveRef.current) return // 组件已卸载:不再更新状态
if (!d) { setUpdateStatus('failed'); return }
localStorage.setItem('dsh.worktable.lastUpdateCheck.v1', String(Date.now()))
const tag = (d.tag_name ?? '').replace(/^v/, '')
if (!tag || cmpVer(tag, LOCAL_VERSION) <= 0) { setUpdateStatus('uptodate'); return }
if ((localStorage.getItem('dsh.worktable.skipVersion.v1') ?? '') === tag) { setUpdateStatus('uptodate'); return }
setUpdateInfo({ latest: tag, notes: (d.body ?? '').slice(0, 800), url: d.html_url ?? '' })
try { localStorage.setItem('dsh.worktable.lastUpdateCheck.v1', String(Date.now())) } catch {}
const tag = typeof d.tag_name === 'string' ? d.tag_name.trim() : ''
const order = cmpVer(tag, LOCAL_VERSION)
if (order == null) { setUpdateStatus('failed'); return }
if (order <= 0) { setUpdateStatus('uptodate'); return }
setUpdateInfo(newerUpdateInfo({ latest: tag.replace(/^v/, ''), notes: typeof d.body === 'string' ? d.body.slice(0, 800) : '', url: typeof d.html_url === 'string' ? d.html_url : '' }))
setUpdateStatus('uptodate')
} finally {
updateCheckingRef.current = false
@@ -1198,15 +1178,10 @@ function WorktableSection(props: any) {
if (!sid) return 'idle'
const e = byId[sid]
if (!e) return 'idle'
if (pendingAckForSession(sid) != null) return 'need'
if (e.completed === true) return 'done'
// 会话面兜底:宿主快照可能滞后或缺少完成字段
try {
const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.()
if (face && face.completed === true) return 'done'
} catch {}
if (e.running === true) return 'busy'
return 'idle'
// Read an existing binding only; never activate a cold session to classify status.
let face: any
try { face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.() } catch {}
return consoleStatusOf(e, pendingAckForSession(sid), face)
}
// 运行时长:任务 startedAt → 新版 chat legacy.turnTimings → 旧会话面;未知不虚构。
const runtimeOf = (sid: string | undefined): number | null => {
@@ -1217,7 +1192,7 @@ function WorktableSection(props: any) {
const make = (id: string, name: string, icon: string, self: boolean): ConsoleCardData => {
const sid = pr.projects.bindings[id]
const status = statusOf(sid)
// 发光 = 完成/待决且本轮未被确认;点卡片确认后熄灭
// 发光 = 本轮结束/待决且未被确认;结束不承诺任务成功。
const glow = !!sid && ((status === 'done' && ackMap[sid] !== 'done') || (status === 'need' && ackMap[sid] !== pendingAckForSession(sid)))
return {
id, name, icon,
@@ -1976,7 +1951,7 @@ function buildCustomLayoutPrompt(req: string): string {
return childSessionIdsOf(sessionsSnapshotStore.snapshot, sid)
}, [])
/** 项目 → 提醒态:绑定会话(含其子代理)待决(黄) > 完成(绿) > 工作中(蓝);
/** 项目 → 提醒态:绑定会话(含其子代理)待决(黄) > 本轮结束(中性) > 工作中(蓝);
* 状态转移自动清除旧 ack——原生 UI 每个新问题都会重新亮黄,镜像不得被旧确认压住 */
const bindNotifyMap: Record<string, 'done' | 'need' | 'busy'> = useMemo(() => {
const map: Record<string, 'done' | 'need' | 'busy'> = {}
+6 -6
View File
@@ -105,7 +105,7 @@ export const zh = {
'bind.titleUnbound': '绑定对话(未绑定 ○○):点击选择要跟随的对话',
'bind.titleBound': '已绑定对话(●●):点击更换或解绑',
'bind.tipUnbound': '未绑定对话:点击选择要跟随的对话',
'bind.tipDone': '(任务已完成,点击项目查看)',
'bind.tipDone': '(本轮已结束,点击项目查看)',
'bind.tipBusy': '(对话正在处理任务…)',
'bind.tipNeed': '(需要你的决定,点击项目处理)',
'bind.folder': '项目文件夹',
@@ -161,9 +161,9 @@ export const zh = {
'console.idle': '空闲',
'console.busy': '工作中',
'console.need': '待你决定',
'console.done': '已完成',
'console.done': '本轮已结束',
'console.kids': '子代理',
'console.noPreview': '暂无消息',
'console.noPreview': '暂无助手回复',
'console.unboundShort': '未绑定对话',
'console.jump': '跳转到绑定对话',
'console.empty': '还没有项目',
@@ -343,7 +343,7 @@ export const en = {
'bind.titleUnbound': 'Bind conversation (unbound ○○): pick the conversation to follow',
'bind.titleBound': 'Conversation bound (●●): click to change or unbind',
'bind.tipUnbound': 'No conversation bound — click to pick one',
'bind.tipDone': ' (task completed — click the project to view)',
'bind.tipDone': ' (run ended — click the project to view)',
'bind.tipBusy': ' (conversation is working on the task…)',
'bind.tipNeed': ' (needs your decision — click the project to handle)',
'bind.folder': 'Project folder',
@@ -399,9 +399,9 @@ export const en = {
'console.idle': 'Idle',
'console.busy': 'Working',
'console.need': 'Needs you',
'console.done': 'Done',
'console.done': 'Run ended',
'console.kids': 'subagents',
'console.noPreview': 'No messages yet',
'console.noPreview': 'No assistant reply yet',
'console.unboundShort': 'No conversation bound',
'console.jump': 'Jump to bound conversation',
'console.empty': 'No projects yet',
+11
View File
@@ -86,6 +86,17 @@ export function createSessionSnapshotAdapter() {
}
}
/** Display only: done means the run ended, never that the task succeeded.
* Keep the adapter's completion edge unchanged for widget-result/ack handling.
*/
export function consoleStatusOf(entry: any, pending: string | null, legacyFace?: any): 'idle' | 'busy' | 'need' | 'done' {
if (!entry) return 'idle'
if (pending != null) return 'need'
if (entry.running === true) return 'busy'
if (entry.completed === true || legacyFace?.completed === true) return 'done'
return 'idle'
}
/** Parent links and the host's child catalog can each contain otherwise missing children. */
export function childSessionIdsOf(snapshot: any, sid: string): Set<string> {
const children = new Set<string>()
+21 -3
View File
@@ -12,13 +12,31 @@ export function previewFromEvents(entries: any): string {
if (!Array.isArray(entries)) return ''
for (let i = entries.length - 1; i >= 0; i--) {
const ev = entries[i]?.event
if (ev?.type !== 'user/message' && ev?.type !== 'assistant/message') continue
const data = ev.type === 'assistant/message' ? ev.data?.message ?? ev.data : ev.data
// user/message also carries injected runtime context. Preview assistant replies only.
if (ev?.type !== 'assistant/message') continue
// New hosts keep model-only replacement copies out of the visible conversation.
if (ev.surfaceOp != null && ev.surfaceOp !== 'append') continue
const data = ev.data?.message ?? ev.data
const blocks = data?.content ?? data?.blocks
if (!Array.isArray(blocks)) continue
const raw = blocks.filter((b: any) => b?.type === 'text' && typeof b.text === 'string').map((b: any) => b.text).join('\n')
const text = cleanPreviewText(raw)
if (text.length >= 8) return text.slice(0, 220)
if (text) return text.slice(0, 220)
}
return ''
}
/** Legacy in-memory nodes have kind-discriminated blocks, not event content blocks. */
export function previewFromNodes(nodes: any): string {
if (!Array.isArray(nodes)) return ''
for (let i = nodes.length - 1; i >= 0; i--) {
const node = nodes[i]
if (node?.kind !== 'assistant' || node.interrupted === true) continue
const blocks = node.blocks
if (!Array.isArray(blocks)) continue
const raw = blocks.filter((b: any) => b?.kind === 'text' && typeof b.text === 'string').map((b: any) => b.text).join('\n')
const text = cleanPreviewText(raw)
if (text) return text.slice(0, 220)
}
return ''
}
+6 -5
View File
@@ -3,7 +3,7 @@ import { parentPathOf } from './pathutil'
import { hostWebSocketUrl, worktableUpgrade } from './hostTransport'
import { SandboxedFrame } from './SandboxedFrame'
import { CHANGELOG_V030 } from './changelog'
import { LOCAL_VERSION, checkUpdate, getAutoCheck, readCache, setAutoCheck as storeAutoCheck, setSkipVersion, type UpdateInfo, type UpdateStatus } from './updateCheck'
import { LOCAL_VERSION, checkUpdate, getAutoCheck, readCache, newerUpdateInfo, setAutoCheck as storeAutoCheck, setSkipVersion, type UpdateInfo, type UpdateStatus } from './updateCheck'
import { Terminal } from 'xterm'
import MarkdownIt from 'markdown-it'
@@ -207,19 +207,19 @@ export type ConsoleCardData = {
id: string
name: string
icon: string
/** 三态 + 空闲:need(待决黄) > done(完成绿) > busy(工作中蓝) > idle */
/** 展示待决/运行/本轮结束/空闲;结束为中性提醒,不判定任务成功。 */
status: 'idle' | 'busy' | 'need' | 'done'
/** 正在运行时的本轮已用毫秒;非运行态 null */
runtimeMs: number | null
/** 子代理数量 */
kids: number
/** 最近一条消息预览(单行文本,可能为空) */
/** 最近可读的助手答复(单行文本,可能为空) */
preview: string
/** 是否绑定对话 */
bound: boolean
/** 是否为「工作台」自己(卡片点击无操作) */
self: boolean
/** 完成/待决且未被确认:卡片发对应色光(点击确认后熄灭) */
/** 本轮结束/待决且未被确认:卡片发提醒光(点击确认后熄灭) */
glow: boolean
}
@@ -1629,7 +1629,8 @@ function ConsolePane() {
const [glowSpeed, setGlowSpeedState] = useState<number>(() => splitEnv?.console?.getGlowSpeed?.() ?? 50)
const [annOpen, setAnnOpen] = useState(false)
const [updStatus, setUpdStatus] = useState<UpdateStatus>(() => readCache().status)
const [updInfo, setUpdInfo] = useState<UpdateInfo | null>(() => readCache().info)
const [rawUpdInfo, setUpdInfo] = useState<UpdateInfo | null>(() => readCache().info)
const updInfo = newerUpdateInfo(rawUpdInfo)
const [autoCheckOn, setAutoCheckOn] = useState<boolean>(() => getAutoCheck())
const updBusyRef = useRef(false)
const photoUrlRef = useRef<string>('')
+5 -5
View File
@@ -198,9 +198,9 @@ export const css = xtermCss + '\n' + [
'.dsh-wt_bindBtn{position:absolute;right:26px;top:50%;transform:translateY(-50%);width:22px;height:22px;margin:0;display:block;border-radius:6px;color:var(--dsw-alias-label-secondary,#9aa4b2);cursor:pointer;user-select:none}',
'.dsh-wt_bindBtn:hover{background:var(--dsw-alias-fill-l2,rgba(255,255,255,.09));color:var(--dsw-alias-label-primary,#e6e8eb)}',
'.dsh-wt_bindBtn[data-bound=true]{color:var(--dsw-alias-label-primary,#e6e8eb)}',
// 任务完成提醒镜像:绿色发光 / 待决提醒:黄色发光(点击项目即 ack 恢复常态实心);
// 本轮结束提醒:中性发光 / 待决提醒:黄色发光(点击项目即 ack 恢复常态实心);
// 工作中:蓝色发光 + 两圆交替亮灭(对应 DSH 对话的转圈标记,无需 ack,running 变 false 自动停)
'.dsh-wt_bindBtn[data-bound=done]{color:#3fb950}',
'.dsh-wt_bindBtn[data-bound=done]{color:#94a3b8}',
'.dsh-wt_bindBtn[data-bound=need]{color:#d29922}',
'.dsh-wt_bindBtn[data-bound=busy]{color:#4f8ef7}',
'@keyframes dsh-wt-busyA{0%,44%{opacity:1}50%,94%{opacity:.12}100%{opacity:1}}',
@@ -581,12 +581,12 @@ export const css = xtermCss + '\n' + [
'.dsh-wt_consoleStatus{flex:none;font-size:22px;font-weight:700;line-height:30px;color:var(--wt-text2)}',
'.dsh-wt_consoleStatus-busy{color:var(--dsw-alias-state-accent-primary,#4f8ef7)}',
'.dsh-wt_consoleStatus-need{color:var(--dsw-alias-state-warning,#d29922)}',
'.dsh-wt_consoleStatus-done{color:var(--dsw-alias-state-success,#3fb950)}',
'.dsh-wt_consoleStatus-done{color:var(--wt-text2)}',
'.dsh-wt_consoleRuntime{flex:none;font-size:17px;font-weight:600;line-height:24px;color:var(--wt-text2);font-variant-numeric:tabular-nums}',
'.dsh-wt_consolePreview{display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden;font-size:8.5px;line-height:13px;color:var(--wt-text2);word-break:break-word}',
'.dsh-wt_consolePreviewNone{color:var(--wt-text3);font-style:italic;-webkit-line-clamp:1}',
// 三色光效:整卡霓虹描边光晕(像侧栏双圆点的发光质感)——完成=绿、待决=黄;工作=蓝色描边顺时针旋转
'.dsh-wt_consoleCard-glowDone{border-color:rgba(63,185,80,.9);box-shadow:0 0 8px rgba(63,185,80,.5),0 0 26px rgba(63,185,80,.3),inset 0 0 16px rgba(63,185,80,.07)}',
// 结束只用中性提醒光,不作为成功证明;待决=黄,工作=蓝色描边顺时针旋转
'.dsh-wt_consoleCard-glowDone{border-color:rgba(148,163,184,.8);box-shadow:0 0 8px rgba(148,163,184,.35),0 0 26px rgba(148,163,184,.2),inset 0 0 16px rgba(148,163,184,.05)}',
'.dsh-wt_consoleCard-glowNeed{border-color:rgba(210,153,34,.95);box-shadow:0 0 8px rgba(210,153,34,.5),0 0 26px rgba(210,153,34,.3),inset 0 0 16px rgba(210,153,34,.08)}',
'@property --consoleAngle{syntax:"<angle>";initial-value:0deg;inherits:false}',
'.dsh-wt_consoleCard-busy{box-shadow:0 0 10px rgba(94,160,255,.55),0 0 34px rgba(79,142,247,.4)}',
+50 -16
View File
@@ -11,14 +11,45 @@ const K_LAST_CHECK = 'dsh.worktable.lastUpdateCheck.v1'
const K_SKIP = 'dsh.worktable.skipVersion.v1'
const K_CACHE = 'dsh.worktable.updateCache.v1'
export function cmpVer(a: string, b: string): number {
const pa = String(a).split('.').map((x) => parseInt(x, 10) || 0)
const pb = String(b).split('.').map((x) => parseInt(x, 10) || 0)
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const d = (pa[i] || 0) - (pb[i] || 0)
if (d !== 0) return d > 0 ? 1 : -1
/** Strict version precedence; invalid input is incomparable, never a potential update. */
function versionParts(value: string): { core: string[]; pre: string[] } | null {
if (typeof value !== 'string') return null
const match = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(value.trim())
if (!match) return null
const pre = match[4]?.split('.') ?? []
if (pre.some((part) => /^\d+$/.test(part) && part.length > 1 && part[0] === '0')) return null
return { core: match.slice(1, 4), pre }
}
function numericOrder(a: string, b: string): number {
if (a.length !== b.length) return a.length > b.length ? 1 : -1
return a === b ? 0 : a > b ? 1 : -1
}
export function cmpVer(a: string, b: string): number | null {
const pa = versionParts(a), pb = versionParts(b)
if (!pa || !pb) return null
for (let i = 0; i < 3; i++) {
const order = numericOrder(pa.core[i], pb.core[i])
if (order !== 0) return order
}
return 0
if (!pa.pre.length || !pb.pre.length) return pa.pre.length ? -1 : pb.pre.length ? 1 : 0
for (let i = 0; i < Math.max(pa.pre.length, pb.pre.length); i++) {
const x = pa.pre[i], y = pb.pre[i]
if (x === undefined || y === undefined) return x === undefined ? -1 : 1
if (x === y) continue
const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y)
if (nx && ny) return numericOrder(x, y)
if (nx !== ny) return nx ? -1 : 1
return x > y ? 1 : -1
}
return 0 // Build metadata has no effect on version precedence.
}
/** Shared by cache reads and both UIs. Preserve identity to avoid render-effect churn. */
export function newerUpdateInfo(value: unknown): UpdateInfo | null {
const info = value as UpdateInfo | null
if (!info || typeof info.latest !== 'string' || typeof info.notes !== 'string' || typeof info.url !== 'string') return null
if (cmpVer(info.latest, LOCAL_VERSION) !== 1 || cmpVer(info.latest, getSkipVersion()) === 0) return null
return info
}
export function getSkipVersion(): string {
@@ -38,21 +69,23 @@ export function readCache(): { status: UpdateStatus; info: UpdateInfo | null } {
const raw = localStorage.getItem(K_CACHE)
if (raw) {
const c = JSON.parse(raw)
if (c && typeof c.status === 'string') {
return { status: c.status === 'checking' ? 'idle' : c.status, info: c.info ?? null }
if (c?.status === 'uptodate' || c?.status === 'failed' || c?.status === 'idle') {
return { status: c.status, info: c.status === 'uptodate' ? newerUpdateInfo(c.info) : null }
}
}
} catch {}
return { status: 'idle', info: null }
}
export function writeCache(status: UpdateStatus, info: UpdateInfo | null) {
try { localStorage.setItem(K_CACHE, JSON.stringify({ status, info })) } catch {}
try { localStorage.setItem(K_CACHE, JSON.stringify({ status, info: status === 'uptodate' ? newerUpdateInfo(info) : null })) } catch {}
}
/** 检查更新:节流一天一次(force 绕过);8s 超时 × 3 重试;防重入由调用方保证 */
export async function checkUpdate(force = false): Promise<{ status: UpdateStatus; info: UpdateInfo | null }> {
const last = Number(localStorage.getItem(K_LAST_CHECK) ?? '0')
if (!force && Date.now() - last < 24 * 3600 * 1000) return readCache()
let last = 0
try { last = Number(localStorage.getItem(K_LAST_CHECK) ?? '0') } catch {}
const age = Date.now() - last
if (!force && Number.isFinite(last) && last > 0 && age >= 0 && age < 24 * 3600 * 1000) return readCache()
try { localStorage.setItem(K_LAST_CHECK, String(Date.now())) } catch {}
let d: { tag_name?: string; body?: string; html_url?: string } | null = null
for (let attempt = 0; attempt < 3 && !d; attempt++) {
@@ -66,10 +99,11 @@ export async function checkUpdate(force = false): Promise<{ status: UpdateStatus
finally { clearTimeout(timer) }
}
if (!d) { writeCache('failed', null); return { status: 'failed', info: null } }
const tag = (d.tag_name ?? '').replace(/^v/, '')
if (!tag || cmpVer(tag, LOCAL_VERSION) <= 0) { writeCache('uptodate', null); return { status: 'uptodate', info: null } }
if (getSkipVersion() === tag) { writeCache('uptodate', null); return { status: 'uptodate', info: null } }
const info: UpdateInfo = { latest: tag, notes: (d.body ?? '').slice(0, 800), url: d.html_url ?? '' }
const tag = typeof d.tag_name === 'string' ? d.tag_name.trim() : ''
const order = cmpVer(tag, LOCAL_VERSION)
if (order == null) { writeCache('failed', null); return { status: 'failed', info: null } }
if (order <= 0) { writeCache('uptodate', null); return { status: 'uptodate', info: null } }
const info = newerUpdateInfo({ latest: tag.replace(/^v/, ''), notes: typeof d.body === 'string' ? d.body.slice(0, 800) : '', url: typeof d.html_url === 'string' ? d.html_url : '' })
writeCache('uptodate', info)
return { status: 'uptodate', info }
}