feat(client): persist digital employee snapshots
This commit is contained in:
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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(";");
|
||||
}
|
||||
@@ -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");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -313,6 +313,43 @@ window.QimingClawBridge.digital.getSnapshot()
|
||||
- 将 bridge snapshot 写入 Run/Event,而不是只作为页面即时 projection。
|
||||
- 让 `/computer/chat` 请求生成真实 Task / Run / Event。
|
||||
|
||||
## Phase 4:qimingclaw 本地状态服务雏形(已开始)
|
||||
|
||||
目标:让数字员工能力不只是页面 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。
|
||||
|
||||
### 产出
|
||||
|
||||
- 数字员工页面可以从源码重新构建。
|
||||
|
||||
Reference in New Issue
Block a user