diff --git a/qimingclaw/crates/agent-electron-client/src/main/ipc/digitalEmployeeHandlers.ts b/qimingclaw/crates/agent-electron-client/src/main/ipc/digitalEmployeeHandlers.ts new file mode 100644 index 00000000..50e2f9b7 --- /dev/null +++ b/qimingclaw/crates/agent-electron-client/src/main/ipc/digitalEmployeeHandlers.ts @@ -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; + 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(); +} diff --git a/qimingclaw/crates/agent-electron-client/src/main/ipc/index.ts b/qimingclaw/crates/agent-electron-client/src/main/ipc/index.ts index 2191960c..3c2fe359 100644 --- a/qimingclaw/crates/agent-electron-client/src/main/ipc/index.ts +++ b/qimingclaw/crates/agent-electron-client/src/main/ipc/index.ts @@ -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"); } diff --git a/qimingclaw/crates/agent-electron-client/src/main/services/digitalEmployee/stateService.ts b/qimingclaw/crates/agent-electron-client/src/main/services/digitalEmployee/stateService.ts new file mode 100644 index 00000000..e7e45291 --- /dev/null +++ b/qimingclaw/crates/agent-electron-client/src/main/services/digitalEmployee/stateService.ts @@ -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; + 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), + 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(";"); +} diff --git a/qimingclaw/crates/agent-electron-client/src/preload/webviewPerfBridge.ts b/qimingclaw/crates/agent-electron-client/src/preload/webviewPerfBridge.ts index 498d44f0..730abc0d 100644 --- a/qimingclaw/crates/agent-electron-client/src/preload/webviewPerfBridge.ts +++ b/qimingclaw/crates/agent-electron-client/src/preload/webviewPerfBridge.ts @@ -1,7 +1,6 @@ import { contextBridge, ipcRenderer } from "electron"; type PerfPayload = Record; -type ServiceStatus = Record | 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"); }, }, }); diff --git a/qimingclaw/docs/digital-employee-frontend-integration-plan.md b/qimingclaw/docs/digital-employee-frontend-integration-plan.md index 6e90f88b..5aae3dd8 100644 --- a/qimingclaw/docs/digital-employee-frontend-integration-plan.md +++ b/qimingclaw/docs/digital-employee-frontend-integration-plan.md @@ -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。 + ### 产出 - 数字员工页面可以从源码重新构建。