feat(client): persist digital employee snapshots

This commit is contained in:
baiyanyun
2026-06-05 16:27:15 +08:00
parent 15a3285ed5
commit 40151c3902
5 changed files with 251 additions and 63 deletions

View File

@@ -0,0 +1,83 @@
import { ipcMain } from "electron";
import type { HandlerContext } from "@shared/types/ipc";
import { FEATURES } from "@shared/featureFlags";
import { agentService } from "../services/engines/unifiedAgent";
import { mcpProxyManager } from "../services/packages/mcp";
import { getComputerServerStatus } from "../services/computerServer";
import { getGuiAgentServerStatus } from "../services/packages/guiAgentServer";
import { getWindowsMcpStatus } from "../services/packages/windowsMcp";
import { isWindows } from "../services/system/shellEnv";
import {
recordDigitalEmployeeSnapshot,
readDigitalEmployeeState,
type DigitalEmployeeServiceStatus,
type DigitalEmployeeSnapshot,
} from "../services/digitalEmployee/stateService";
export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
ipcMain.handle("digitalEmployee:getSnapshot", async () => {
const snapshot = buildSnapshot(ctx);
const state = recordDigitalEmployeeSnapshot(snapshot);
return {
...snapshot,
state: {
updatedAt: state.updatedAt,
events: state.events,
},
};
});
ipcMain.handle("digitalEmployee:getState", async () => {
return readDigitalEmployeeState();
});
}
function buildSnapshot(ctx: HandlerContext): DigitalEmployeeSnapshot {
const sessions = agentService.listAllSessionsDetailed();
return {
generatedAt: new Date().toISOString(),
services: {
agent: {
running: agentService.isReady,
engineType: agentService.getEngineType(),
},
mcp: normalizeServiceStatus(mcpProxyManager.getStatus()),
computerServer: normalizeServiceStatus(getComputerServerStatus()),
lanproxy: normalizeServiceStatus(ctx.lanproxy.status()),
fileServer: normalizeServiceStatus(ctx.fileServer.status()),
guiServer: normalizeServiceStatus(getGuiStatus()),
},
sessions: {
total: sessions.length,
active: sessions.filter((session) =>
session.status === "active" || session.status === "pending",
).length,
items: sessions.slice(0, 10),
},
};
}
function normalizeServiceStatus(
value: object | null | undefined,
): DigitalEmployeeServiceStatus {
if (!value) return { running: false };
const record = value as Record<string, unknown>;
return {
running: record.running === true,
error: typeof record.error === "string" ? record.error : undefined,
engineType: typeof record.engineType === "string" ? record.engineType : undefined,
port: typeof record.port === "number" ? record.port : undefined,
serverCount: typeof record.serverCount === "number" ? record.serverCount : undefined,
serverNames: Array.isArray(record.serverNames)
? record.serverNames.filter((item): item is string => typeof item === "string")
: undefined,
pid: typeof record.pid === "number" ? record.pid : undefined,
};
}
function getGuiStatus(): object {
if (!FEATURES.ENABLE_GUI_AGENT_SERVER) {
return { running: false, error: "GUI Agent Server is not available" };
}
return isWindows() ? getWindowsMcpStatus() : getGuiAgentServerStatus();
}

View File

@@ -14,6 +14,7 @@ import { registerSandboxHandlers } from "./sandboxHandlers";
import { registerPerfHandlers } from "./perfHandlers";
import { registerGuiServerHandlers } from "./guiServerHandlers";
import { registerI18nHandlers } from "./i18nHandlers";
import { registerDigitalEmployeeHandlers } from "./digitalEmployeeHandlers";
import log from "electron-log";
export function registerAllHandlers(ctx: HandlerContext): void {
@@ -32,6 +33,7 @@ export function registerAllHandlers(ctx: HandlerContext): void {
registerPerfHandlers();
registerGuiServerHandlers();
registerI18nHandlers();
registerDigitalEmployeeHandlers(ctx);
log.info("IPC handlers registered");
}

View File

@@ -0,0 +1,128 @@
import { readSetting, writeSetting } from "../../db";
const STATE_KEY = "digital_employee_state_v1";
const MAX_EVENTS = 200;
export interface DigitalEmployeeServiceStatus {
running?: boolean;
error?: string;
engineType?: string | null;
port?: number;
serverCount?: number;
serverNames?: string[];
pid?: number;
}
export interface DigitalEmployeeSessionSummary {
total: number;
active: number;
items: Array<{
id: string;
title?: string;
status?: string;
engineType?: string;
projectId?: string;
}>;
}
export interface DigitalEmployeeSnapshot {
generatedAt: string;
services: Record<string, DigitalEmployeeServiceStatus | null>;
sessions: DigitalEmployeeSessionSummary;
}
export interface DigitalEmployeeStateEvent {
event_id: string;
kind: string;
occurred_at: string;
message: string;
plan_id: string | null;
task_id: string | null;
}
export interface DigitalEmployeeState {
version: 1;
updatedAt: string | null;
lastSnapshot: DigitalEmployeeSnapshot | null;
events: DigitalEmployeeStateEvent[];
}
function defaultState(): DigitalEmployeeState {
return {
version: 1,
updatedAt: null,
lastSnapshot: null,
events: [],
};
}
export function readDigitalEmployeeState(): DigitalEmployeeState {
const state = readSetting(STATE_KEY);
if (!state || typeof state !== "object") {
return defaultState();
}
return {
...defaultState(),
...(state as Partial<DigitalEmployeeState>),
version: 1,
};
}
export function writeDigitalEmployeeState(state: DigitalEmployeeState): void {
writeSetting(STATE_KEY, state);
}
export function recordDigitalEmployeeSnapshot(
snapshot: DigitalEmployeeSnapshot,
): DigitalEmployeeState {
const previous = readDigitalEmployeeState();
const events = [...previous.events];
const event = eventFromSnapshot(snapshot, previous.lastSnapshot);
if (event) {
events.unshift(event);
}
const next: DigitalEmployeeState = {
version: 1,
updatedAt: snapshot.generatedAt,
lastSnapshot: snapshot,
events: events.slice(0, MAX_EVENTS),
};
writeDigitalEmployeeState(next);
return next;
}
function eventFromSnapshot(
snapshot: DigitalEmployeeSnapshot,
previous: DigitalEmployeeSnapshot | null,
): DigitalEmployeeStateEvent | null {
const currentSummary = summarizeSnapshot(snapshot);
const previousSummary = previous ? summarizeSnapshot(previous) : "";
if (currentSummary === previousSummary) {
return null;
}
return {
event_id: `qimingclaw-snapshot-${Date.now()}`,
kind: "service_snapshot",
occurred_at: snapshot.generatedAt,
message: currentSummary,
plan_id: "qimingclaw-client-readiness",
task_id: "task-agent-status",
};
}
function summarizeSnapshot(snapshot: DigitalEmployeeSnapshot): string {
const serviceNames = [
["agent", "Agent"],
["mcp", "MCP"],
["computerServer", "Computer Server"],
["lanproxy", "Lanproxy"],
["fileServer", "File Server"],
] as const;
const services = serviceNames.map(([key, label]) => {
const status = snapshot.services[key];
return `${label}:${status?.running ? "running" : "stopped"}`;
});
services.push(`sessions:${snapshot.sessions.active}/${snapshot.sessions.total}`);
return services.join(";");
}

View File

@@ -1,7 +1,6 @@
import { contextBridge, ipcRenderer } from "electron";
type PerfPayload = Record<string, unknown>;
type ServiceStatus = Record<string, unknown> | null;
const CHAT_ROUTE_RE = /^\/home\/chat\/\d+\/\d+$/;
const CHAT_ROOT_SELECTOR = '[data-qimingclaw-perf-scope="chat-root"]';
@@ -94,68 +93,7 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
perf,
digital: {
async getSnapshot() {
const [
agent,
mcp,
computerServer,
lanproxy,
fileServer,
guiServer,
sessionsResult,
] = await Promise.all([
ipcRenderer.invoke("agent:serviceStatus").catch((error) => ({
running: false,
error: String(error),
})),
ipcRenderer.invoke("mcp:status").catch((error) => ({
running: false,
error: String(error),
})),
ipcRenderer.invoke("computerServer:status").catch((error) => ({
running: false,
error: String(error),
})),
ipcRenderer.invoke("lanproxy:status").catch((error) => ({
running: false,
error: String(error),
})),
ipcRenderer.invoke("fileServer:status").catch((error) => ({
running: false,
error: String(error),
})),
ipcRenderer.invoke("guiServer:status").catch((error) => ({
running: false,
error: String(error),
})),
ipcRenderer.invoke("agent:listSessionsDetailed").catch((error) => ({
success: false,
data: [],
error: String(error),
})),
]);
const sessions = Array.isArray(sessionsResult?.data)
? sessionsResult.data
: [];
return {
generatedAt: new Date().toISOString(),
services: {
agent: agent as ServiceStatus,
mcp: mcp as ServiceStatus,
computerServer: computerServer as ServiceStatus,
lanproxy: lanproxy as ServiceStatus,
fileServer: fileServer as ServiceStatus,
guiServer: guiServer as ServiceStatus,
},
sessions: {
total: sessions.length,
active: sessions.filter((session: { status?: string }) =>
session.status === "active" || session.status === "pending",
).length,
items: sessions.slice(0, 10),
},
};
return ipcRenderer.invoke("digitalEmployee:getSnapshot");
},
},
});

View File

@@ -313,6 +313,43 @@ window.QimingClawBridge.digital.getSnapshot()
- 将 bridge snapshot 写入 Run/Event而不是只作为页面即时 projection。
-`/computer/chat` 请求生成真实 Task / Run / Event。
## Phase 4qimingclaw 本地状态服务雏形(已开始)
目标:让数字员工能力不只是页面 projection而是开始拥有 qimingclaw 自己的本地状态沉淀。
当前 qimingclaw 已新增:
```text
crates/agent-electron-client/src/main/services/digitalEmployee/stateService.ts
crates/agent-electron-client/src/main/ipc/digitalEmployeeHandlers.ts
```
新增 IPC
```text
digitalEmployee:getSnapshot
digitalEmployee:getState
```
当前职责:
- 主进程统一采集 Agent / MCP / Computer Server / Lanproxy / File Server / GUI Server / Sessions。
- 每次 snapshot 写入 `digital_employee_state_v1`
- 状态服务生成轻量事件 `service_snapshot`
- webview bridge 改为调用 `digitalEmployee:getSnapshot`,不再自行聚合多个 IPC。
当前边界:
- 状态服务仍是轻量版本,先使用 settings 表持久化。
- Plan / Task / Run / Event 还没有独立 SQLite 表。
- 当前事件主要记录服务状态变化,还没有接入 `/computer/chat` 和 ACP token/event stream。
下一步:
- 新建正式 SQLite 表:`digital_plans``digital_tasks``digital_runs``digital_events``digital_artifacts``digital_approvals`
- 在 computer server 的 `/computer/chat` 入口创建 Task / Run。
- 在 ACP event forwarder 中写入 Run/Event。
### 产出
- 数字员工页面可以从源码重新构建。