From 2163a7a0928787e97860045d5749224606fe1e57 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 18:53:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(gui+ohos):=20=E5=90=8C=E6=AD=A5=20WebUI=20?= =?UTF-8?q?=E6=80=BB=E8=A7=88=E6=94=B9=E7=89=88=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E6=A1=8C=E9=9D=A2=E7=89=88=E8=A1=A5=E8=BF=90=E8=A1=8C=E6=80=81?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=EF=BC=8C=E4=B8=A4=E7=AB=AF=E8=A1=A5=E5=86=85?= =?UTF-8?q?=E6=A0=B8=E8=BA=AB=E4=BB=BD=E4=B8=8E=E5=BC=80=E6=BA=90=E8=AE=B8?= =?UTF-8?q?=E5=8F=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebUI 那边这几轮改完,桌面 app(Electron)与鸿蒙 app(ArkTS)要跟上。 先摸了底:**两个 app 此前都没有运行态面板**(阶段管道 / 中断队列), 所以这不是"移植",是新做;emoji 图标两端本来就没有,无需处理。 ## 桌面 app(cmd/gui/renderer) 1) 运行态面板(新)—— 与 WebUI 同一套设计语言:**等大表框** - 数据源 /api/v1/runtime(此前只拉 /status 与 /kernel)。 - 四个数字块沿用本 app 的 statCard(排队/中断/栈/子代理)。 - 阶段管道:5 个等大框,框内是本阶段本轮发生的事件 chip; 当前阶段整框点亮。图标一律内联 SVG(含「工具会循环」标记)。 - 中断队列:5 个等大框(L4/L3/L2/L1 + 排队)一行排开, 级别名 16px/800、深度 26px/800、可见格槽(0 时也有形状); 有积压整框描边点亮;排队队列虚线框区分(另一类别,不是另一优先级)。 - stage SSE 事件接上轨迹(rtTrailPush,同阶段同一条累加 xN), 2.5s 无新事件回空闲。 - 列宽 repeat(auto-fit, minmax(100px,1fr)):窄容器也保证 5 框一行, 不出「4 个 + 1 个」的孤行。 2) 版本身份(修一个真缺陷) 旧实现是 `s.version || "0.1.0"`:拿不到数据时**向用户展示一个不存在的 版本号** 0.1.0。改为取 /kernel 的 build(-ldflags 注入的真实版本/commit), 并在版本号下补一行「内核名 · commit」。 3) 开源许可卡(新) 协议标识 + 协议全文 + 源码仓库 + §13 说明;网络条款按标识是否含 AGPL 决定是否渲染,不硬写协议名。 ## 鸿蒙 app(cmd/ohos/HomeAgent) 4) StatusStore 解析 /kernel 的 build:补 内核版本 / Commit / SDK 兼容 / 构建时间 到「内核」分组;K_VERSION 统一成 v<版本>,新增 K_BUILD 广播 「内核名 · commit」给摘要卡(版本号本身没有内核身份)。 5) 新增「开源许可」分组:许可协议 / 协议全文 / 源码仓库 / 网络条款说明。 ## 验证 - 桌面:共享浏览器加载 renderer(桩掉 preload 桥)后喂真实形状数据渲染 —— 版本卡 `v1.4.0+hotfix.f89e57a` + `HomeAgent · f89e57a`;管道 `输入=输入 | 行动=思考 | 工具=qq_get_message x3 qq* | 输出=生成 | 结束=完成`; 队列 `L4:0 | L3:3 on=3[active] | L2:2 on=2[active] | L1:0 | 排队:2 on=2[active]`; 许可卡两个链接均为 target=_blank + rel=noopener noreferrer;无 emoji。 node --check 通过。 - 鸿蒙:/opt/huawei/command-line-tools/bin/hvigorw assembleHap **BUILD SUCCESSFUL**。 ## 未做(下一条腿) 鸿蒙端的运行态面板(阶段管道 + 中断队列)**还没有**。它比桌面端贵: 需要新组件(5 框管道 + 5 框队列)、把 /runtime 快照接入 StatusStore, 以及把 SSE 的 stage 事件从 ChatSse 的 sink 引到状态侧——后者是接口改动。 「状态」Tab 此前已被有意删除(并入设置页 + 二级明细),面板落点也要定 (建议放二级明细页顶部)。桌面的实现可直接作参照。 --- cmd/gui/renderer/app.js | 283 +++++++++++++++++- cmd/gui/renderer/style.css | 240 +++++++++++++++ .../entry/src/main/ets/common/StatusStore.ets | 61 ++++ .../src/main/ets/components/StatusCards.ets | 30 +- 4 files changed, 601 insertions(+), 13 deletions(-) diff --git a/cmd/gui/renderer/app.js b/cmd/gui/renderer/app.js index 7696700..6f21fb9 100644 --- a/cmd/gui/renderer/app.js +++ b/cmd/gui/renderer/app.js @@ -696,6 +696,10 @@ async function refreshDataOnly() { try { state.kernel = await api("/kernel"); } catch (e) {} + try { + // 运行态快照:只给指标与队列/阶段展示用,不影响其他卡片。 + state.runtime = await api("/runtime"); + } catch (e) {} try { var s = await api("/settings"); state.settings = s.settings || {}; @@ -775,6 +779,10 @@ async function refreshAll() { try { state.kernel = await api("/kernel"); } catch (e) {} + try { + // 运行态快照(调度器/驻留子/通道),供总览的运行态面板使用。 + state.runtime = await api("/runtime"); + } catch (e) {} try { var s = await api("/settings"); state.settings = s.settings || {}; @@ -1004,6 +1012,223 @@ function statCard(l, v) { ); } +// ===== 运行态面板:阶段管道 + 中断队列 ===== +// +// 与 WebUI 总览**同一套设计语言:等大表框**。此前桌面版总览只有四个数字卡, +// 既看不到「这一轮走到哪一步」,也看不到四级中断队列的积压。 +// 数据来自 /api/v1/runtime(KernelStatus 的运行态子集)。 +var RT_LEVELS = [ + { lv: 4, name: "L4", zh: "内核独占", en: "kernel only", cls: "rt-lv-4" }, + { lv: 3, name: "L3", zh: "交互", en: "interactive", cls: "rt-lv-3" }, + { lv: 2, name: "L2", zh: "消息", en: "message", cls: "rt-lv-2" }, + { lv: 1, name: "L1", zh: "后台", en: "background", cls: "rt-lv-1" }, +]; +// 七阶段归并成五格(与内核 sdk.Stage 的顺序一致): +// 一轮里工具调用会反复回到「行动后」,线性滑块本身就是错的表述, +// 所以画成 输入 → 行动 ⇄(工具) → 输出 → 结束,工具那格带循环标记。 +var RT_PIPE_GROUPS = [ + { zh: "输入", en: "in", ico: "in" }, + { zh: "行动", en: "act", ico: "act" }, + { zh: "工具", en: "tool", ico: "tool", loop: true }, + { zh: "输出", en: "out", ico: "out" }, + { zh: "结束", en: "done", ico: "done" }, +]; +// 图标一律内联 SVG(24x24 / currentColor),不用 emoji/符号字符充当图标。 +var RT_ICO = { + in: '', + act: '', + tool: '', + out: '', + done: '', + loop: '', +}; + +function rtPhaseGroup(phase) { + switch (phase) { + case "on_input": + return 0; + case "pre_action": + case "post_action": + return 1; + case "before_toolcall": + case "after_toolcall": + return 2; + case "before_output": + return 3; + case "after_output": + return 4; + } + return -1; +} +function rtShortTool(name) { + var n = String(name || ""); + var i = n.lastIndexOf("__"); + if (i >= 0) n = n.slice(i + 2); + return n.length > 14 ? n.slice(0, 13) + "…" : n; +} +// rtSlots 画一组「车位」式格槽:槽位数量固定可见,被占用的点亮。 +// 为什么不用进度条:队列为 0 时进度条宽度就是 0,整行只剩文字,看上去就是「这块空着」。 +function rtSlots(depth, slots, cls) { + var n = Math.max(5, Math.min(16, slots || 5)); + var d = depth || 0; + var out = ''; + for (var i = 0; i < n; i++) out += ''; + // 溢出计数必须留在 .rt-slots 内:格槽是 flex 行,多一个兄弟节点会被挤出去 + if (d > n) out += '+' + (d - n) + ""; + return out + ""; +} +// rtTrailPush 把一条「本轮发生过的事」落到它实际发生的阶段列里; +// 同一阶段重复的同一条(如同一工具连调 3 次)只累加计数,不刷屏。 +function rtTrailPush(g, kind, label, short) { + if (!state.stageTrail) state.stageTrail = []; + var arr = state.stageTrail; + var last = arr.length ? arr[arr.length - 1] : null; + if (last && last.g === g && last.kind === kind && last.short === short) { + last.n = (last.n || 1) + 1; + return; + } + arr.push({ g: g, kind: kind, label: label, short: short, n: 1 }); + if (arr.length > 24) arr.shift(); +} + +function renderRuntimePanel() { + var title = __("运行态", "Runtime"); + var rt = state.runtime; + if (!rt) { + return ( + '

' + title + '

' + + __("运行态数据不可用", "runtime unavailable") + "

" + ); + } + var sc = rt.scheduler || {}; + var q = sc.interrupt_queues || [0, 0, 0, 0, 0]; + var pending = sc.pending_interrupts || 0; + var ready = sc.ready_queue_depth || 0; + var stack = sc.suspend_stack || 0; + var maxStack = sc.max_suspend_depth || 4; + var residents = rt.residents || []; + var byLv = sc.interrupts_by_level || []; + var preLv = sc.preempts_by_level || []; + var maxQ = Math.max(1, ready, q[1] || 0, q[2] || 0, q[3] || 0, q[4] || 0); + // 至少 5 格:0 时也有可见形状 + var qSlots = Math.max(5, Math.min(16, maxQ)); + + var html = '

' + title + "

"; + // 四个数字块(沿用本 app 的 statCard 风格) + html += + '
' + + statCard(__("排队", "Ready"), ready, "") + + statCard(__("中断", "Pending"), pending, "") + + statCard(__("栈", "Stack"), stack + "/" + maxStack, "") + + statCard(__("子代理", "Subagents"), residents.length, "") + + "
"; + + // ---- 阶段管道:五个等大表框 ---- + var g = rtPhaseGroup(state.pipelinePhase || ""); + html += + '
' + __("阶段管道", "Stage pipeline") + + (g < 0 ? " " + __("(空闲)", "(idle)") : "") + "
"; + html += '
'; + html += RT_PIPE_GROUPS.map(function (s, i) { + var items = (state.stageTrail || []).filter(function (t) { + return (t.g | 0) === i; + }); + var body = items.length + ? items + .map(function (t) { + var kind = t.kind || "stage"; + var ico = + kind === "output" ? RT_ICO.out : kind === "tool" ? RT_ICO.tool : ""; + return ( + '' + ico + + escHtml(t.short || t.label) + + (t.n > 1 ? 'x' + t.n + "" : "") + + "" + ); + }) + .join("") + : '' + __("无", "none") + ""; + return ( + '
' + + '
' + RT_ICO[s.ico] + + "" + __(s.zh, s.en) + "" + + (s.loop + ? '' + RT_ICO.loop + "" + : "") + + '
' + body + "
" + ); + }).join(""); + html += "
"; + + // ---- 中断队列:五个等大表框(L4/L3/L2/L1 + 排队)---- + html += '
' + __("队列", "Queues") + "
"; + html += '
'; + RT_LEVELS.forEach(function (L) { + var depth = q[L.lv] || 0; + var reg = byLv[L.lv] || 0; + var pre = preLv[L.lv] || 0; + var desc = __(L.zh, L.en); + html += + '
' + + '
' + L.name + "" + escHtml(desc) + "
" + + '
' + depth + "
" + + rtSlots(depth, qSlots, L.cls) + + '
' + reg + " " + __("登记", "reg") + " · " + + pre + " " + __("抢占", "pre") + "
"; + }); + // 排队队列无级别:用虚线框与四级中断区分(另一**类别**,不是另一优先级) + html += + '
' + + '
' + __("排队", "queued") + "FIFO
" + + '
' + ready + "
" + + rtSlots(ready, qSlots, "rt-lv-q") + + '
' + __("无级别", "no priority") + "
"; + html += "
"; + return html; +} + +// 开源许可卡:协议标识 + 协议全文 + 源码仓库。 +// AGPL-3.0 §13 的义务是「向网络使用者提供取得 Corresponding Source 的机会」—— +// 只给一个仓库链接、不写协议名,使用者看不出这受什么许可约束。 +function renderLegalCard() { + var b = ((state.kernel || {}).build) || {}; + var src = b.source_url || ""; + var lic = b.license || ""; + var licURL = b.license_url || ""; + if (!lic && !src) return ""; + function row(key, val) { + return ( + '
' + escHtml(key) + + '' + val + "
" + ); + } + function a(href, text) { + return ( + '' + escHtml(text) + "" + ); + } + var rows = ""; + if (lic) rows += row(__("许可协议", "License"), licURL ? a(licURL, lic) : escHtml(lic)); + if (src) rows += row(__("源码仓库", "Source"), a(src, src)); + // 网络条款只在 AGPL 系的许可下才成立,所以按标识判断,不硬写协议名。 + var note = + lic && lic.toUpperCase().indexOf("AGPL") >= 0 + ? '

' + + __( + "网络服务条款(§13):把修改后的版本作为网络服务对外提供时,必须向使用者提供取得对应源码的途径。", + "Network clause (section 13): offering a modified version as a network service requires giving users a way to obtain the Corresponding Source.", + ) + + "

" + : ""; + return '

' + __("开源许可", "License") + "

" + rows + note + "
"; +} + function renderOverview() { var s = state.status || {}; var k = state.kernel; @@ -1018,7 +1243,25 @@ function renderOverview() { "uptime", ) + statCard(__("插件", "Plugins"), (k?.plugins || []).length || 0, "plugin") + - statCard(__("版本", "Version"), s.version || "0.1.0", "version") + + statCard( + __("版本", "Version"), + (function () { + // 构建身份取自 /kernel 的 build(-ldflags 注入的真实版本/commit)。 + // 旧实现用的是 /status 的 version 加一个凭空写死的 "0.1.0" 兑底 —— + // 拿不到数据时会向用户展示一个不存在的版本号。 + var b = (k && k.build) || {}; + var v = b.version || s.version || ""; + if (!v) return "-"; + var sha = + b.commit && b.commit !== "unknown" ? String(b.commit).slice(0, 7) : ""; + return ( + "v" + escHtml(v) + + '
' + escHtml(b.kernel_name || "HomeAgent") + + (sha ? " · " + escHtml(sha) : "") + "
" + ); + })(), + "version", + ) + ""; if (k) { html += @@ -1082,6 +1325,7 @@ function renderOverview() { "" + ""; } + html += renderRuntimePanel(); html += '

' + __("运行时", "Runtime") + @@ -1094,6 +1338,7 @@ function renderOverview() { ) + statCard("Go " + __("版本", "Version"), k?.runtime?.go_version || "-", "") + "

"; + html += renderLegalCard(); document.getElementById("view-overview").innerHTML = html; } @@ -5211,17 +5456,47 @@ async function connectFetchSSE(url) { var phase = p.phase || ""; var tool = p.tool || ""; if (p.channel !== "_consolidation_") { - if (phase === "pre_action") + // 阶段轨迹:本轮真实发生过什么,按阶段落到运行态面板的对应框里。 + // 与 WebUI 同一套 g(阶段组)编号,见 rtPhaseGroup。 + if (phase === "on_input") { + state.stageTrail = []; + rtTrailPush(0, "stage", __("输入", "input"), __("输入", "input")); + } else if (phase === "pre_action") { + rtTrailPush( + 1, + "stage", + __("组装上下文并思考", "assemble context and think"), + __("思考", "think"), + ); state.chatStage = __("AI 思考中...", "AI thinking..."); - else if (phase === "before_toolcall") { + } else if (phase === "before_toolcall") { + if (tool) + rtTrailPush( + 2, + tool.indexOf("output_") === 0 ? "output" : "tool", + tool, + rtShortTool(tool), + ); state.chatStage = __("工具调用: ", "Tool: ") + (tool || ""); if (tool && (state.pendingTools || []).indexOf(tool) === -1) { if (!state.pendingTools) state.pendingTools = []; state.pendingTools.push(tool); rerenderChatIfActive(); } - } else if (phase === "before_output") + } else if (phase === "before_output") { + rtTrailPush(3, "stage", __("生成回复", "generate reply"), __("生成", "gen")); state.chatStage = __("生成回复中...", "Generating response..."); + } else if (phase === "after_output") { + rtTrailPush(4, "stage", __("本轮完成", "turn complete"), __("完成", "done")); + } + state.pipelinePhase = phase; + // 阶段停留一会儿就回空闲,避免留下一个永远停在 after_output 的假状态。 + if (state.pipelineTimer) clearTimeout(state.pipelineTimer); + state.pipelineTimer = setTimeout(function () { + state.pipelinePhase = ""; + if (state.currentView === "overview") renderOverview(); + }, 2500); + if (state.currentView === "overview") renderOverview(); } var badge = document.getElementById("chat-stage"); if (badge) { diff --git a/cmd/gui/renderer/style.css b/cmd/gui/renderer/style.css index 0d83af4..75cfbd2 100644 --- a/cmd/gui/renderer/style.css +++ b/cmd/gui/renderer/style.css @@ -2191,3 +2191,243 @@ td .switch { .kv-row .val .switch { margin-right: 4px; } + +/* ===== 运行态面板:阶段管道 + 中断队列(与 WebUI 总览同一套设计语言)===== + 两块都统一成**等大表框**,与 KPI 卡同一种骨架。 + 此前桌面版总览只有四个数字卡:既看不到「这一轮走到哪一步」, + 也看不到四级中断队列的积压。 */ +.stat-card .stat-sub { + margin-top: 4px; + font-size: 11px; + font-weight: 500; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} +.rt-section-title { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + letter-spacing: 0.3px; + margin: 14px 0 8px; + text-transform: uppercase; +} +.rt-empty { + font-size: 12px; + color: var(--text-muted); + padding: 4px 0; +} +/* 行内小图标:一律 SVG,不使用 emoji/符号字符。 */ +.rt-ico { + width: 12px; + height: 12px; + flex: 0 0 auto; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + vertical-align: -1.5px; +} +.rt-pipe-row { + display: grid; + /* 100px 下限:容器再窄也要保证 5 个框一行,不留「4 个 + 1 个」的孤行 */ + grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); + gap: 10px; + margin: 2px 0 8px; + transition: opacity 0.3s var(--ease-out); +} +.rt-pipe-idle { + opacity: 0.55; +} +.rt-pipe-cell { + display: flex; + flex-direction: column; + gap: 8px; + padding: 11px 12px 10px; + border-radius: var(--radius-md); + background: var(--bg-input); + border: 1px solid var(--border-color); + min-width: 0; + transition: + border-color 0.25s var(--ease-out), + box-shadow 0.25s var(--ease-out), + background 0.25s var(--ease-out); +} +.rt-pipe-cell.active { + border-color: var(--accent); + background: var(--accent-bg); + box-shadow: var(--shadow-md); +} +.rt-pipe-head { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} +.rt-pipe-head > b { + font-size: 13.5px; + font-weight: 700; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.rt-pipe-head .rt-ico { + width: 15px; + height: 15px; + color: var(--text-secondary); +} +.rt-pipe-cell.active .rt-pipe-head > b, +.rt-pipe-cell.active .rt-pipe-head .rt-ico { + color: var(--accent); +} +.rt-pipe-events { + display: flex; + flex-wrap: wrap; + gap: 4px; + min-height: 23px; +} +.rt-loop { + display: inline-flex; + color: var(--accent); + font-weight: 700; +} +.rt-chip { + display: inline-flex; + align-items: center; + gap: 3px; + min-width: 0; + max-width: 170px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; + padding: 2px 8px; + border-radius: var(--radius-pill); + background: var(--bg-hover); + border: 1px solid var(--border-color); + color: var(--text-secondary); +} +.rt-chip-tool { + border-color: rgba(136, 192, 208, 0.42); + color: var(--frost-300); +} +.rt-chip-output { + border-color: var(--accent); + color: var(--accent); +} +.rt-chip-stage { + opacity: 0.7; +} +.rt-chip-none { + opacity: 0.35; + border-style: dashed; +} +.rt-chip-n { + font-style: normal; + opacity: 0.75; + font-variant-numeric: tabular-nums; +} +/* 队列:5 个等大框(L4/L3/L2/L1 + 排队),一行排开 */ +.rt-queues { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); + gap: 10px; + margin-bottom: 4px; +} +.rt-qcell { + display: flex; + flex-direction: column; + gap: 7px; + padding: 11px 12px 10px; + border-radius: var(--radius-md); + background: var(--bg-input); + border: 1px solid var(--border-color); + min-width: 0; + transition: + border-color 0.25s var(--ease-out), + box-shadow 0.25s var(--ease-out); +} +.rt-qhead { + display: flex; + align-items: baseline; + gap: 7px; + min-width: 0; +} +.rt-qhead > b { + font-size: 16px; + font-weight: 800; + line-height: 1; + letter-spacing: -0.02em; +} +.rt-qhead > span { + font-size: 10.5px; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.rt-qnum { + font-size: 26px; + font-weight: 800; + line-height: 1; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; +} +.rt-qmeta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px 8px; + font-size: 10.5px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} +.rt-qcell.rt-lv-4 .rt-qhead > b { color: #ff5c7a; } +.rt-qcell.rt-lv-3 .rt-qhead > b { color: #ffa657; } +.rt-qcell.rt-lv-2 .rt-qhead > b { color: var(--sakura-400); } +.rt-qcell.rt-lv-1 .rt-qhead > b { color: var(--frost-300); } +.rt-qcell.rt-lv-q .rt-qhead > b { color: #a3be8c; } +/* 有积压时整个框描边点亮:一眼看出哪条队列在堵 */ +.rt-qcell.rt-lv-4.rt-active { border-color: rgba(255, 92, 122, 0.55); } +.rt-qcell.rt-lv-3.rt-active { border-color: rgba(255, 166, 87, 0.55); } +.rt-qcell.rt-lv-2.rt-active { border-color: var(--sakura-400); } +.rt-qcell.rt-lv-1.rt-active { border-color: var(--frost-300); } +.rt-qcell.rt-lv-q.rt-active { border-color: #a3be8c; } +/* 排队队列无级别:虚线框与四级中断区分(另一**类别**,不是另一优先级) */ +.rt-qcell.rt-qcell-queued { + border-style: dashed; +} +/* 队列格槽:固定可见的「车位」,占用多少一眼可数。 + 用进度条时队列为 0 宽度就是 0,整行只剩文字,看上去就是「这块空着」。 */ +.rt-slots { + display: flex; + align-items: stretch; + gap: 3px; + height: 16px; + min-width: 0; +} +.rt-slots > i { + flex: 1 1 0; + min-width: 3px; + border-radius: 3px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.05); + transition: + background 0.25s var(--ease-out), + box-shadow 0.25s var(--ease-out); +} +.rt-slots.rt-lv-4 > i.on { background: #ff5c7a; box-shadow: 0 0 6px rgba(255, 92, 122, 0.45); } +.rt-slots.rt-lv-3 > i.on { background: #ffa657; box-shadow: 0 0 6px rgba(255, 166, 87, 0.4); } +.rt-slots.rt-lv-2 > i.on { background: var(--sakura-400); box-shadow: 0 0 6px rgba(255, 127, 172, 0.4); } +.rt-slots.rt-lv-1 > i.on { background: var(--frost-300); box-shadow: 0 0 6px rgba(136, 192, 208, 0.4); } +.rt-slots.rt-lv-q > i.on { background: #a3be8c; box-shadow: 0 0 6px rgba(163, 190, 140, 0.4); } +.rt-slots-more { + flex: 0 0 auto; + align-self: center; + margin-left: 4px; + font-size: 10px; + font-weight: 600; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/StatusStore.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/StatusStore.ets index 59e8d9a..0861ce8 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/common/StatusStore.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/StatusStore.ets @@ -37,6 +37,8 @@ export const K_TOOLS: string = 'statTools'; export const K_ERR: string = 'statErr'; export const K_LOADING: string = 'statLoading'; export const K_REV: string = 'statRev'; +/** 内核身份副行:内核名 · commit。版本号光有一个号码分不清是哪个内核、哪次构建。 */ +export const K_BUILD: string = 'statBuildSub'; class StatusStore { /** 明细分组:只有明细页读它,不进 AppStorage(数组同步语义太脆) */ @@ -52,6 +54,7 @@ class StatusStore { AppStorage.setOrCreate(K_ERR, ''); AppStorage.setOrCreate(K_LOADING, false); AppStorage.setOrCreate(K_REV, 0); + AppStorage.setOrCreate(K_BUILD, ''); } getGroups(): StatGroup[] { @@ -133,8 +136,66 @@ class StatusStore { if (startTime.length > 0) { kernelFields.push({ label: '内核启动', value: formatTime(startTime) }); } + + // 构建身份:版本 / commit / SDK 兼容都取自 /kernel 的 build(-ldflags 注入)。 + // 为何不能只用 /status 的 version:那里只有一个版本号,分不清是哪个内核、 + // 哪次构建;许可标识更是完全没有。 + const build: Record | undefined = k['build'] as Record; + let lic: string = ''; + let licURL: string = ''; + let srcURL: string = ''; + if (build !== undefined && build !== null) { + const bVer: string = build['version'] as string ?? ''; + const bCommit: string = build['commit'] as string ?? ''; + const bName: string = build['kernel_name'] as string ?? 'HomeAgent'; + const bSdk: string = build['sdk_compatible'] as string ?? ''; + const bTime: string = build['build_time'] as string ?? ''; + lic = build['license'] as string ?? ''; + licURL = build['license_url'] as string ?? ''; + srcURL = build['source_url'] as string ?? ''; + if (bVer.length > 0) { + AppStorage.set(K_VERSION, 'v' + bVer); + kernelFields.push({ label: '内核版本', value: 'v' + bVer }); + } + let sub: string = bName; + if (bCommit.length > 0 && bCommit !== 'unknown') { + sub = sub + ' · ' + bCommit.substring(0, 7); + } + AppStorage.set(K_BUILD, sub); + if (bCommit.length > 0) { + kernelFields.push({ label: 'Commit', value: bCommit }); + } + if (bSdk.length > 0) { + kernelFields.push({ label: 'SDK 兼容', value: bSdk }); + } + if (bTime.length > 0 && bTime !== 'unknown') { + kernelFields.push({ label: '构建时间', value: formatTime(bTime) }); + } + } groups.push({ title: '内核', fields: kernelFields }); + // 开源许可:只给一个源码链接、不写协议名,使用者看不出这受什么许可约束, + // 也看不出网络服务场景下的 §13 义务。 + const legalFields: StatField[] = []; + if (lic.length > 0) { + legalFields.push({ label: '许可协议', value: lic }); + } + if (licURL.length > 0) { + legalFields.push({ label: '协议全文', value: licURL }); + } + if (srcURL.length > 0) { + legalFields.push({ label: '源码仓库', value: srcURL }); + } + if (legalFields.length > 0) { + if (lic.toUpperCase().indexOf('AGPL') >= 0) { + legalFields.push({ + label: '网络条款', + value: '把修改后的版本作为网络服务对外提供时,必须向使用者提供取得对应源码的途径(§13)', + }); + } + groups.push({ title: '开源许可', fields: legalFields }); + } + // LLM:provider / 可用源 / 是否可用 const llm: Record | undefined = k['llm'] as Record; if (llm !== undefined && llm !== null) { diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/StatusCards.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/StatusCards.ets index 29b06f3..1045785 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/components/StatusCards.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/StatusCards.ets @@ -4,7 +4,7 @@ import { COLOR_ACCENT, COLOR_SUCCESS, COLOR_CYAN, COLOR_ERROR } from '../common/ import { MotionBase } from './MotionBase'; import { statusStore, StatGroup, StatField, compactDuration, - K_UP, K_VERSION, K_STARTED, K_AGENTS, K_PLUGINS, K_TOOLS, K_ERR, K_LOADING, K_REV, + K_UP, K_VERSION, K_STARTED, K_AGENTS, K_PLUGINS, K_TOOLS, K_ERR, K_LOADING, K_REV, K_BUILD, } from '../common/StatusStore'; /** @@ -19,6 +19,8 @@ export struct StatusSummaryCard { @StorageProp('themeIsDark') private isDark: boolean = true; @StorageProp(K_UP) private up: boolean = false; @StorageProp(K_VERSION) private version: string = '-'; + /** 内核名 · commit。与版本号分开一行:版本号本身没有内核身份。 */ + @StorageProp(K_BUILD) private buildSub: string = ''; @StorageProp(K_STARTED) private startedAt: string = ''; @StorageProp(K_AGENTS) private agents: number = 0; @StorageProp(K_PLUGINS) private plugins: number = 0; @@ -130,14 +132,24 @@ export struct StatusSummaryCard { Text('版本') .fontSize(12) .fontColor(this.palette().textMuted) - Text(this.version) - .fontSize(12) - .fontWeight(FontWeight.Medium) - .fontColor(this.palette().textSecondary) - .layoutWeight(1) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .margin({ left: 10 }) + Column() { + Text(this.version) + .fontSize(12) + .fontWeight(FontWeight.Medium) + .fontColor(this.palette().textSecondary) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + // 内核名 + commit:光有版本号会分不清是哪个内核、哪次构建 + Text(this.buildSub) + .fontSize(10) + .fontColor(this.palette().textMuted) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .visibility(this.buildSub.length > 0 ? Visibility.Visible : Visibility.None) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + .margin({ left: 10 }) Text('明细') .fontSize(12) .fontColor(this.palette().accent)