diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSse.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSse.ets index a387ecb..c289bb5 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSse.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSse.ets @@ -10,6 +10,7 @@ import { ChatMessage, ToolCallInfo, ChatAttachment } from '../model/Model'; import { attachmentFromChannelOutput } from './AttachmentMeta'; import { SseEvent } from './SseClient'; import { stringifyField } from './ChatFormat'; +import { stageTrail } from './StageTrail'; import { hilog } from '@kit.PerformanceAnalysisKit'; /** 聊天流状态机对外暴露的最小写入面(由 common/ChatStore.ets 实现) */ @@ -168,6 +169,9 @@ export function applyChatSse(ev: SseEvent, sink: ChatStreamSink): void { const channel: string = payload['channel'] as string ?? ''; const stageTool: string = payload['tool'] as string ?? ''; if (channel !== '_consolidation_') { + // 运行态面板的阶段管道靠这条轨迹活着:先喂轨迹,再管聊天侧的角标。 + // 两者是独立消费者,轨迹不依赖任何聊天状态。 + stageTrail.onStage(phase, stageTool); if (phase === 'pre_action') { sink.setStage('AI 思考中...'); } else if (phase === 'before_toolcall') { diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/StageTrail.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/StageTrail.ets new file mode 100644 index 0000000..251eff0 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/StageTrail.ets @@ -0,0 +1,180 @@ +/** + * 阶段轨迹(单例):本轮对话在七个内核阶段里真实发生过什么。 + * + * 由 ChatSse 在收到 `stage` 事件时喂入,运行态面板读取。 + * 为什么不放进 StatusStore:轨迹来自 SSE 流、与 /status、/kernel 的轮询无关, + * 两者的生命周期和失败模式都不一样,混在一个数据源里会互相拖累 + * (SSE 断连不该让状态卡变空,状态轮询失败也不该清掉轨迹)。 + * + * 七阶段归并成五格(与内核 sdk.Stage 的顺序一致): + * 一轮里工具调用会反复回到「行动后」,线性滑块本身就是错的表述, + * 所以画成 输入 → 行动 ⇄(工具) → 输出 → 结束,工具那格带循环标记。 + */ + +/** 一个阶段组 */ +export interface StageGroup { + /** 0 输入 / 1 行动 / 2 工具 / 3 输出 / 4 结束 */ + group: number; + label: string; + en: string; +} + +/** 轨迹里的一条事件 */ +export interface StageEvent { + group: number; + /** 'stage' | 'tool' | 'output' */ + kind: string; + label: string; + short: string; + count: number; +} + +/** 阶段组定义,索引即 group */ +export const STAGE_GROUPS: StageGroup[] = [ + { group: 0, label: '输入', en: 'in' }, + { group: 1, label: '行动', en: 'act' }, + { group: 2, label: '工具', en: 'tool' }, + { group: 3, label: '输出', en: 'out' }, + { group: 4, label: '结束', en: 'done' }, +]; + +/** 当前阶段(SSE 驱动)。空串 = 空闲。 */ +export const K_STAGE_PHASE: string = 'stagePhase'; +/** 轨迹版本号:数组不进 AppStorage,靠它触发订阅组件重取快照 */ +export const K_STAGE_REV: string = 'stageRev'; + +/** 阶段停留多久后回「空闲」——否则会留下一个永远停在 after_output 的假状态 */ +const IDLE_AFTER_MS: number = 2500; +const MAX_ITEMS: number = 24; + +/** rtShortTool 把 `qq_get_message` / `output_send__qq` 压成尾段短名 */export function shortTool(name: string): string { + let n: string = name; + const i: number = n.lastIndexOf('__'); + if (i >= 0) { + n = n.substring(i + 2); + } + return n.length > 14 ? n.substring(0, 13) + '…' : n; +} + +class StageTrail { + private items: StageEvent[] = []; + private timerId: number = -1; + + init(): void { + AppStorage.setOrCreate(K_STAGE_PHASE, ''); + AppStorage.setOrCreate(K_STAGE_REV, 0); + } + + /** 只给订阅组件读;调用方不要持有它 */ + snapshot(): StageEvent[] { + return this.items; + } + + currentPhase(): string { + return AppStorage.get(K_STAGE_PHASE) ?? ''; + } + + /** 按阶段分组取条目 */ + eventsOf(group: number): StageEvent[] { + const out: StageEvent[] = []; + for (let i = 0; i < this.items.length; i++) { + const e: StageEvent = this.items[i]; + if (e.group === group) { + out.push(e); + } + } + return out; + } + + /** + * 收到一条 stage 事件。phase 取值与内核一致: + * on_input / pre_action / post_action / before_toolcall / after_toolcall / + * before_output / after_output。 + */ + onStage(phase: string, tool: string): void { + if (phase.length === 0) { + return; + } + if (phase === 'on_input') { + // 新的一轮:清空上一轮的轨迹 + this.items = []; + this.push(0, 'stage', '输入', '输入'); + } else if (phase === 'pre_action') { + this.push(1, 'stage', '组装上下文并思考', '思考'); + } else if (phase === 'before_toolcall' && tool.length > 0) { + // output_* 是输出通道工具,与普通工具用不同配色区分 + const kind: string = tool.indexOf('output_') === 0 ? 'output' : 'tool'; + this.push(2, kind, tool, shortTool(tool)); + } else if (phase === 'before_output') { + this.push(3, 'stage', '生成回复', '生成'); + } else if (phase === 'after_output') { + this.push(4, 'stage', '本轮完成', '完成'); + } else { + // post_action / after_toolcall 不单独记:它们与相邻格重复, + // 逐条记会把轨迹刷成噪音。 + AppStorage.set(K_STAGE_PHASE, phase); + this.armIdleTimer(); + return; + } + AppStorage.set(K_STAGE_PHASE, phase); + this.armIdleTimer(); + this.bump(); + } + + private push(group: number, kind: string, label: string, short: string): void { + // 同一阶段重复出现的同一条(如同一工具连调 3 次)只累加计数,不刷屏 + const n: number = this.items.length; + if (n > 0) { + const last: StageEvent = this.items[n - 1]; + if (last.group === group && last.kind === kind && last.short === short) { + last.count = last.count + 1; + return; + } + } + this.items.push({ group: group, kind: kind, label: label, short: short, count: 1 }); + if (this.items.length > MAX_ITEMS) { + this.items.shift(); + } + } + + private armIdleTimer(): void { + if (this.timerId !== -1) { + clearTimeout(this.timerId); + } + this.timerId = setTimeout(() => { + this.timerId = -1; + AppStorage.set(K_STAGE_PHASE, ''); + this.bump(); + }, IDLE_AFTER_MS); + } + + private bump(): void { + const cur: number = AppStorage.get(K_STAGE_REV) ?? 0; + AppStorage.set(K_STAGE_REV, cur + 1); + } +} + +export const stageTrail: StageTrail = new StageTrail(); + +/** + * 内核的七个阶段归并到五个展示格。顺序与 sdk.Stage 一致, + * 所以「当前阶段」直接看返回值是不是当前格。空闲返回 -1。 + */ +export function phaseGroup(phase: string): number { + if (phase === 'on_input') { + return 0; + } + if (phase === 'pre_action' || phase === 'post_action') { + return 1; + } + if (phase === 'before_toolcall' || phase === 'after_toolcall') { + return 2; + } + if (phase === 'before_output') { + return 3; + } + if (phase === 'after_output') { + return 4; + } + return -1; +} 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 0861ce8..5170d92 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/common/StatusStore.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/StatusStore.ets @@ -27,6 +27,68 @@ export interface StatGroup { fields: StatField[]; } +// ===== 运行态快照(/runtime)===== +// +// 与 StatGroup 的分工:明细卡回答「内核有哪些东西、多少」, +// 运行态回答「现在在干什么」——四级中断队列积压多少、有几个驻留子。 +// 两者数据源不同(/kernel vs /runtime),所以分开取、分开存。 + +/** 一条队列(四级中断之一,或排队队列) */ +export interface RuntimeQueue { + /** 4/3/2/1;0 表示排队队列(无级别) */ + lv: number; + name: string; + desc: string; + depth: number; + registered: number; + preempted: number; +} + +/** 一次刷新的运行态快照 */ +export interface RuntimeSnapshot { + /** 排队队列深度(无级别,纯 FIFO) */ + ready: number; + pending: number; + stack: number; + maxStack: number; + subagents: number; + /** 格槽数(按全场最大深度缩放,至少 5) */ + slots: number; + /** 五条队列:L4/L3/L2/L1 + 排队 */ + queues: RuntimeQueue[]; +} + +interface LevelDef { + lv: number; + name: string; + desc: string; +} + +/** 四级中断的定义(顺序即 L4→L1) */ +const LEVEL_DEFS: LevelDef[] = [ + { lv: 4, name: 'L4', desc: '内核独占' }, + { lv: 3, name: 'L3', desc: '交互' }, + { lv: 2, name: 'L2', desc: '消息' }, + { lv: 1, name: 'L1', desc: '后台' }, +]; + +/** JSON 数组里按下标取数:越界/类型不符都当 0 */ +function numAt(arr: Object[] | undefined, i: number): number { + if (arr === undefined || arr === null || i < 0 || i >= arr.length) { + return 0; + } + const v: number = arr[i] as number; + return isNaN(v) ? 0 : v; +} + +/** 格槽数:按全场最大深度缩放,至少 5 格(0 时也要有可见形状)、最多 16 格 */ +function clampSlots(maxQ: number): number { + if (maxQ < 5) { + return 5; + } + return maxQ > 16 ? 16 : maxQ; +} + // ===== AppStorage 键:摘要卡与明细页共用 ===== export const K_UP: string = 'statUp'; export const K_VERSION: string = 'statVersion'; @@ -43,6 +105,8 @@ export const K_BUILD: string = 'statBuildSub'; class StatusStore { /** 明细分组:只有明细页读它,不进 AppStorage(数组同步语义太脆) */ private groups: StatGroup[] = []; + /** 运行态快照:同上,靠 K_REV 触发订阅组件重取 */ + private runtimeSnapshot: RuntimeSnapshot | undefined = undefined; init(): void { AppStorage.setOrCreate(K_UP, false); @@ -61,6 +125,10 @@ class StatusStore { return this.groups; } + getRuntime(): RuntimeSnapshot | undefined { + return this.runtimeSnapshot; + } + isUp(): boolean { return AppStorage.get(K_UP) ?? false; } @@ -104,6 +172,7 @@ class StatusStore { ]; await this.collectKernel(groups); + await this.collectRuntime(); this.groups = groups; this.bump(); } catch (e) { @@ -282,11 +351,91 @@ class StatusStore { } } + /** + * 取运行态快照(/runtime)。失败不影响已经取到的明细分组: + * 旧后端可能没有这个端点,那时面板显示「运行态数据不可用」即可。 + */ + private async collectRuntime(): Promise { + try { + const resp = await apiClient.getWithTimeout('/runtime', 8000); + const r: Record = JSON.parse(resp.body) as Record; + + const sc: Record | undefined = r['scheduler'] as Record; + const residents: Object[] | undefined = r['residents'] as Object[]; + + let ready: number = 0; + let pending: number = 0; + let stack: number = 0; + let maxStack: number = 4; + let qArr: Object[] | undefined = undefined; + let regArr: Object[] | undefined = undefined; + let preArr: Object[] | undefined = undefined; + if (sc !== undefined && sc !== null) { + ready = sc['ready_queue_depth'] as number ?? 0; + pending = sc['pending_interrupts'] as number ?? 0; + stack = sc['suspend_stack'] as number ?? 0; + maxStack = sc['max_suspend_depth'] as number ?? 4; + qArr = sc['interrupt_queues'] as Object[]; + regArr = sc['interrupts_by_level'] as Object[]; + preArr = sc['preempts_by_level'] as Object[]; + } + + // 四级语义引用内核的定义(internal/agent/core/scheduler.go), + // 前端只负责把它们画出来,不自己起名字。 + const queues: RuntimeQueue[] = []; + let maxQ: number = 1; + for (let i = 0; i < LEVEL_DEFS.length; i++) { + const d: LevelDef = LEVEL_DEFS[i]; + const depth: number = numAt(qArr, d.lv); + if (depth > maxQ) { + maxQ = depth; + } + queues.push({ + lv: d.lv, + name: d.name, + desc: d.desc, + depth: depth, + registered: numAt(regArr, d.lv), + preempted: numAt(preArr, d.lv), + }); + } + if (ready > maxQ) { + maxQ = ready; + } + // 第五条:排队队列。它不是优先级,而是另一**类别**(排队 vs 中断), + // 所以 lv 用 0 标记「无级别」。 + queues.push({ + lv: 0, + name: '排队', + desc: 'FIFO', + depth: ready, + registered: 0, + preempted: 0, + }); + + const subagents: number = residents !== undefined && residents !== null + ? residents.length : 0; + + this.runtimeSnapshot = { + ready: ready, + pending: pending, + stack: stack, + maxStack: maxStack, + subagents: subagents, + slots: clampSlots(maxQ), + queues: queues, + }; + } catch (e) { + // /runtime 不可用(旧后端):保留上一次快照,不清空 + } + } + private fail(msg: string): void { AppStorage.set(K_ERR, msg); AppStorage.set(K_UP, false); AppStorage.set(K_LOADING, false); this.groups = []; + this.runtimeSnapshot = undefined; this.bump(); } diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/RuntimePanel.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/RuntimePanel.ets new file mode 100644 index 0000000..f587a71 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/RuntimePanel.ets @@ -0,0 +1,332 @@ +import { + ThemePalette, DARK_PALETTE, LIGHT_PALETTE, + RADIUS_SM, RADIUS_MD, ANIM_FAST, + COLOR_ACCENT, COLOR_EMERALD, COLOR_FROST_300, +} from '../common/Constants'; +import { statusStore, RuntimeSnapshot, RuntimeQueue, K_REV } from '../common/StatusStore'; +import { + stageTrail, StageEvent, StageGroup, STAGE_GROUPS, phaseGroup, + K_STAGE_PHASE, K_STAGE_REV, +} from '../common/StageTrail'; + +/** 四级中断 + 排队的配色:与 WebUI 总览同一套(级别色贯穿框头、槽位、描边) */ +const LV_COLORS: string[] = ['#A3BE8C', '#4A90D9', '#0A59F7', '#FFA657', '#FF5C7A']; +/** 索引 = lv(0 排队 / 1 L1 / 2 L2 / 3 L3 / 4 L4) */ +function lvColor(lv: number): string { + if (lv < 0 || lv > 4) { + return COLOR_ACCENT; + } + return LV_COLORS[lv]; +} + +/** + * 运行态面板:阶段管道 + 中断队列。 + * + * 与 WebUI / 桌面版同一套设计语言:**等大表框**。 + * 此前鸿蒙端完全没有运行态展示(「状态」Tab 已并入设置页 + 二级明细), + * 这里补在二级明细页顶部。 + * + * 两块数据来源不同: + * - 队列 / 计数:StatusStore 轮询 /runtime,靠 K_REV 通知; + * - 阶段 + 本轮轨迹:StageTrail 由 SSE 的 stage 事件喂入,靠 K_STAGE_REV 通知。 + */ +@Component +export struct RuntimePanel { + @StorageProp('themeIsDark') private isDark: boolean = true; + /** 宽屏(平板/折叠)5 框一行;手机 3+2。窄屏硬塞 5 框会把标签挤成省略号。 */ + @StorageProp('isWideScreen') private isWide: boolean = false; + @StorageProp(K_REV) @Watch('onStatusRev') private statusRev: number = 0; + @StorageProp(K_STAGE_REV) @Watch('onStageRev') private stageRev: number = 0; + @StorageProp(K_STAGE_PHASE) private phase: string = ''; + @State private snap: RuntimeSnapshot | undefined = undefined; + /** 格槽下标:ArkUI 的 ForEach 遍历的是数组,所以把「几格」摊成下标数组 */ + @State private slotIdx: number[] = []; + @State private trail: StageEvent[] = []; + + aboutToAppear(): void { + this.pull(); + } + + private onStatusRev(): void { + this.pull(); + } + + private onStageRev(): void { + this.trail = stageTrail.snapshot(); + } + + private pull(): void { + const s: RuntimeSnapshot | undefined = statusStore.getRuntime(); + this.snap = s; + if (s !== undefined) { + const idx: number[] = []; + for (let i = 0; i < s.slots; i++) { + idx.push(i); + } + this.slotIdx = idx; + } else { + this.slotIdx = []; + } + this.trail = stageTrail.snapshot(); + } + + private palette(): ThemePalette { + return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; + } + + /** 该阶段本轮发生的事件 */ + private eventsOf(group: number): StageEvent[] { + const out: StageEvent[] = []; + for (let i = 0; i < this.trail.length; i++) { + const e: StageEvent = this.trail[i]; + if (e.group === group) { + out.push(e); + } + } + return out; + } + + @Builder + tile(label: string, value: string, warn: boolean) { + Column({ space: 2 }) { + Text(value) + .fontSize(20) + .fontWeight(FontWeight.Bold) + .fontColor(warn ? this.palette().accent : this.palette().textPrimary) + .maxLines(1) + Text(label) + .fontSize(11) + .fontColor(this.palette().textMuted) + .maxLines(1) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + .padding({ left: 10, right: 10, top: 9, bottom: 9 }) + .borderRadius(RADIUS_SM) + .backgroundColor(this.palette().bgHover) + } + + /** 阶段管道的一格 */ + @Builder + stageCell(g: StageGroup, active: boolean) { + Column({ space: 6 }) { + Row({ space: 5 }) { + // 用一个几何圆点而非图标:本项目没有为「阶段」准备的图形资源, + // 而猜 sys.media.* 名称会直接编译不过;也不允许用 emoji 充当图标。 + // 不用 Circle().fill():那是 SDK 26 起的 API,本工程兼容版本是 6.1.1(24)。 + Row() + .width(7) + .height(7) + .borderRadius(4) + .backgroundColor(active ? this.palette().accent : this.palette().textMuted) + Text(g.label) + .fontSize(11) + .fontWeight(FontWeight.Bold) + .fontColor(active ? this.palette().accent : this.palette().textSecondary) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .width('100%') + + // 本阶段本轮发生的事件;没有就给一个弱化的「无」,让框不空着 + Column({ space: 3 }) { + ForEach(this.eventsOf(g.group), (e: StageEvent) => { + Row({ space: 3 }) { + Text(e.short) + .fontSize(9) + .fontColor(e.kind === 'output' ? this.palette().accent + : (e.kind === 'tool' ? COLOR_FROST_300 : this.palette().textSecondary)) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + if (e.count > 1) { + Text('x' + e.count.toString()) + .fontSize(9) + .fontColor(this.palette().textMuted) + } + } + .width('100%') + }, (e: StageEvent, i: number) => i.toString() + ':' + e.short) + + if (this.eventsOf(g.group).length === 0) { + Text('无') + .fontSize(9) + .fontColor(this.palette().textMuted) + .opacity(0.5) + } + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + .padding({ left: 8, right: 8, top: 9, bottom: 9 }) + .borderRadius(RADIUS_SM) + .backgroundColor(active ? this.palette().accentBg : this.palette().bgHover) + .border({ width: 1, color: active ? this.palette().accent : this.palette().glassBorder }) + .animation({ duration: ANIM_FAST, curve: Curve.EaseOut }) + } + + /** 中断队列的一格 */ + @Builder + queueCell(q: RuntimeQueue) { + Column({ space: 5 }) { + Row({ space: 4 }) { + Text(q.name) + .fontSize(13) + .fontWeight(FontWeight.Bold) + .fontColor(lvColor(q.lv)) + .maxLines(1) + Text(q.desc) + .fontSize(9) + .fontColor(this.palette().textMuted) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .width('100%') + .alignItems(VerticalAlign.Bottom) + + Text(q.depth.toString()) + .fontSize(20) + .fontWeight(FontWeight.Bold) + .fontColor(this.palette().textPrimary) + .maxLines(1) + + // 格槽:固定可见的「车位」。用进度条时深度为 0 宽度就是 0, + // 整格只剩文字,看上去就是「这块空着」。 + Row({ space: 2 }) { + ForEach(this.slotIdx, (i: number) => { + Row() + .layoutWeight(1) + .height(11) + .borderRadius(2) + .backgroundColor(i < q.depth ? lvColor(q.lv) : this.palette().bgHover) + }, (i: number) => q.lv.toString() + '-' + i.toString()) + } + .width('100%') + + if (q.lv > 0) { + Text(q.registered.toString() + ' 登记 · ' + q.preempted.toString() + ' 抢占') + .fontSize(9) + .fontColor(this.palette().textMuted) + .maxLines(1) + } else { + Text('无级别') + .fontSize(9) + .fontColor(this.palette().textMuted) + .maxLines(1) + } + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + .padding({ left: 8, right: 8, top: 9, bottom: 9 }) + .borderRadius(RADIUS_SM) + .backgroundColor(this.palette().bgHover) + .border({ + width: 1, + color: q.depth > 0 ? lvColor(q.lv) : this.palette().glassBorder, + style: q.lv === 0 ? BorderStyle.Dashed : BorderStyle.Solid, + }) + .animation({ duration: ANIM_FAST, curve: Curve.EaseOut }) + } + + build() { + Column() { + Text('运行态') + .fontSize(15) + .fontWeight(FontWeight.Medium) + .fontColor(this.palette().textPrimary) + .margin({ bottom: 8 }) + + if (this.snap === undefined) { + Text('运行态数据不可用') + .fontSize(12) + .fontColor(this.palette().textMuted) + .padding({ top: 4, bottom: 4 }) + } else { + // 四个数字块 + Row({ space: 8 }) { + this.tile('排队', this.snap.ready.toString(), this.snap.ready > 0) + this.tile('中断', this.snap.pending.toString(), this.snap.pending > 0) + this.tile('栈', this.snap.stack.toString() + '/' + this.snap.maxStack.toString(), + this.snap.stack > 0) + this.tile('子代理', this.snap.subagents.toString(), false) + } + .width('100%') + + // 阶段管道:等大表框,事件落在所属阶段那一格 + Text('阶段管道') + .fontSize(12) + .fontWeight(FontWeight.Medium) + .fontColor(this.palette().textSecondary) + .margin({ top: 14, bottom: 6 }) + if (this.isWide) { + Row({ space: 6 }) { + ForEach(STAGE_GROUPS, (g: StageGroup) => { + this.stageCell(g, phaseGroup(this.phase) === g.group) + }, (g: StageGroup) => 'w' + g.group.toString()) + } + .width('100%') + .alignItems(VerticalAlign.Top) + } else { + Row({ space: 6 }) { + ForEach(STAGE_GROUPS.slice(0, 3), (g: StageGroup) => { + this.stageCell(g, phaseGroup(this.phase) === g.group) + }, (g: StageGroup) => 'n0' + g.group.toString()) + } + .width('100%') + .alignItems(VerticalAlign.Top) + Row({ space: 6 }) { + ForEach(STAGE_GROUPS.slice(3), (g: StageGroup) => { + this.stageCell(g, phaseGroup(this.phase) === g.group) + }, (g: StageGroup) => 'n1' + g.group.toString()) + // 占位:第二行只有 2 格,补一格位置让框宽与第一行对齐 + Row().layoutWeight(1) + } + .width('100%') + .alignItems(VerticalAlign.Top) + .margin({ top: 6 }) + } + + // 中断队列:五个等大表框(L4/L3/L2/L1 + 排队) + Text('队列') + .fontSize(12) + .fontWeight(FontWeight.Medium) + .fontColor(this.palette().textSecondary) + .margin({ top: 14, bottom: 6 }) + if (this.isWide) { + Row({ space: 6 }) { + ForEach(this.snap.queues, (q: RuntimeQueue) => { + this.queueCell(q) + }, (q: RuntimeQueue) => 'wq' + q.lv.toString()) + } + .width('100%') + .alignItems(VerticalAlign.Top) + } else { + Row({ space: 6 }) { + ForEach(this.snap.queues.slice(0, 3), (q: RuntimeQueue) => { + this.queueCell(q) + }, (q: RuntimeQueue) => 'nq0' + q.lv.toString()) + } + .width('100%') + .alignItems(VerticalAlign.Top) + Row({ space: 6 }) { + ForEach(this.snap.queues.slice(3), (q: RuntimeQueue) => { + this.queueCell(q) + }, (q: RuntimeQueue) => 'nq1' + q.lv.toString()) + Row().layoutWeight(1) + } + .width('100%') + .alignItems(VerticalAlign.Top) + .margin({ top: 6 }) + } + } + } + .width('100%') + .alignItems(HorizontalAlign.Start) + .padding(16) + .margin({ bottom: 14 }) + .borderRadius(RADIUS_MD) + .backgroundColor(this.palette().bgCard) + .border({ width: 1, color: this.palette().glassBorder }) + } +} 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 1045785..88b80e0 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/components/StatusCards.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/StatusCards.ets @@ -2,6 +2,7 @@ import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_SM, ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants'; import { COLOR_ACCENT, COLOR_SUCCESS, COLOR_CYAN, COLOR_ERROR } from '../common/Constants'; import { MotionBase } from './MotionBase'; +import { RuntimePanel } from './RuntimePanel'; import { statusStore, StatGroup, StatField, compactDuration, K_UP, K_VERSION, K_STARTED, K_AGENTS, K_PLUGINS, K_TOOLS, K_ERR, K_LOADING, K_REV, K_BUILD, @@ -283,6 +284,11 @@ export struct StatusDetailContent { // 于是 31 个插件在 203 个工具旁边只剩一条短线 —— 读数没有意义。 // 数量本身已在摘要卡上以图标+数字直观呈现,这里不再重复。 + // 运行态面板(阶段管道 + 中断队列)放在明细页最前: + // 明细卡回答「内核有哪些东西、多少」,运行态回答「现在在干什么」, + // 后者是进这个页面最先想看的。 + RuntimePanel() + ForEach(this.groups, (g: StatGroup) => { Column() { Text(g.title) diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/SettingsPage.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/SettingsPage.ets index a6bafdf..b1ccef3 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/SettingsPage.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/SettingsPage.ets @@ -17,6 +17,7 @@ import { SUB_NONE, SUB_STATUS, SUB_CONNECTIONS, SUB_APPEARANCE, SUB_BACKEND, SUB_SECTION, } from '../common/SettingsModel'; import { statusStore } from '../common/StatusStore'; +import { stageTrail } from '../common/StageTrail'; interface SaveSettingBody { key: string; @@ -72,6 +73,8 @@ export struct SettingsPage { this.loadBackendSettings(); // 状态 Tab 已删除,运行状态并入本页:顶部摘要卡 + 二级明细页 statusStore.init(); + // 阶段轨迹由 SSE 喂入,与状态轮询是两条独立的数据源 + stageTrail.init(); statusStore.refresh(); }