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 { registerPerfHandlers } from "./perfHandlers";
|
||||||
import { registerGuiServerHandlers } from "./guiServerHandlers";
|
import { registerGuiServerHandlers } from "./guiServerHandlers";
|
||||||
import { registerI18nHandlers } from "./i18nHandlers";
|
import { registerI18nHandlers } from "./i18nHandlers";
|
||||||
|
import { registerDigitalEmployeeHandlers } from "./digitalEmployeeHandlers";
|
||||||
import log from "electron-log";
|
import log from "electron-log";
|
||||||
|
|
||||||
export function registerAllHandlers(ctx: HandlerContext): void {
|
export function registerAllHandlers(ctx: HandlerContext): void {
|
||||||
@@ -32,6 +33,7 @@ export function registerAllHandlers(ctx: HandlerContext): void {
|
|||||||
registerPerfHandlers();
|
registerPerfHandlers();
|
||||||
registerGuiServerHandlers();
|
registerGuiServerHandlers();
|
||||||
registerI18nHandlers();
|
registerI18nHandlers();
|
||||||
|
registerDigitalEmployeeHandlers(ctx);
|
||||||
|
|
||||||
log.info("IPC handlers registered");
|
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";
|
import { contextBridge, ipcRenderer } from "electron";
|
||||||
|
|
||||||
type PerfPayload = Record<string, unknown>;
|
type PerfPayload = Record<string, unknown>;
|
||||||
type ServiceStatus = Record<string, unknown> | null;
|
|
||||||
|
|
||||||
const CHAT_ROUTE_RE = /^\/home\/chat\/\d+\/\d+$/;
|
const CHAT_ROUTE_RE = /^\/home\/chat\/\d+\/\d+$/;
|
||||||
const CHAT_ROOT_SELECTOR = '[data-qimingclaw-perf-scope="chat-root"]';
|
const CHAT_ROOT_SELECTOR = '[data-qimingclaw-perf-scope="chat-root"]';
|
||||||
@@ -94,68 +93,7 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
|
|||||||
perf,
|
perf,
|
||||||
digital: {
|
digital: {
|
||||||
async getSnapshot() {
|
async getSnapshot() {
|
||||||
const [
|
return ipcRenderer.invoke("digitalEmployee:getSnapshot");
|
||||||
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),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -313,6 +313,43 @@ window.QimingClawBridge.digital.getSnapshot()
|
|||||||
- 将 bridge snapshot 写入 Run/Event,而不是只作为页面即时 projection。
|
- 将 bridge snapshot 写入 Run/Event,而不是只作为页面即时 projection。
|
||||||
- 让 `/computer/chat` 请求生成真实 Task / Run / Event。
|
- 让 `/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