fix(v0.5.1): 适配rc.5并修正控制室与更新提醒
This commit is contained in:
@@ -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)
|
||||
|
||||
🛡️ 新版宿主兼容、安全隔离与侧栏修复
|
||||
|
||||
|
||||
@@ -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'> = {}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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 ''
|
||||
}
|
||||
|
||||
@@ -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>('')
|
||||
|
||||
@@ -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)}',
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user