feat(client): record computer tasks for digital employee
This commit is contained in:
@@ -1,5 +1,9 @@
|
|||||||
import { ipcMain } from "electron";
|
import { ipcMain } from "electron";
|
||||||
import { agentService } from "../services/engines/unifiedAgent";
|
import { agentService } from "../services/engines/unifiedAgent";
|
||||||
|
import {
|
||||||
|
recordDigitalEmployeeChatReceived,
|
||||||
|
recordDigitalEmployeeChatResult,
|
||||||
|
} from "../services/digitalEmployee/stateService";
|
||||||
import type {
|
import type {
|
||||||
ComputerChatRequest,
|
ComputerChatRequest,
|
||||||
ComputerAgentStatusResponse,
|
ComputerAgentStatusResponse,
|
||||||
@@ -10,11 +14,17 @@ import type {
|
|||||||
|
|
||||||
export function registerComputerHandlers(): void {
|
export function registerComputerHandlers(): void {
|
||||||
ipcMain.handle("computer:chat", async (_, request: ComputerChatRequest) => {
|
ipcMain.handle("computer:chat", async (_, request: ComputerChatRequest) => {
|
||||||
|
recordDigitalEmployeeChatReceived(request);
|
||||||
// 与 HTTP 路径一致:按 project_id 路由到对应 AcpEngine
|
// 与 HTTP 路径一致:按 project_id 路由到对应 AcpEngine
|
||||||
let acpEngine;
|
let acpEngine;
|
||||||
try {
|
try {
|
||||||
acpEngine = await agentService.ensureEngineForRequest(request);
|
acpEngine = await agentService.ensureEngineForRequest(request);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
recordDigitalEmployeeChatResult(request, {
|
||||||
|
success: false,
|
||||||
|
code: "5000",
|
||||||
|
message: err.message || "Engine switch failed",
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
code: "5000",
|
code: "5000",
|
||||||
message: err.message || "Engine switch failed",
|
message: err.message || "Engine switch failed",
|
||||||
@@ -24,6 +34,11 @@ export function registerComputerHandlers(): void {
|
|||||||
} as HttpResult;
|
} as HttpResult;
|
||||||
}
|
}
|
||||||
if (!acpEngine) {
|
if (!acpEngine) {
|
||||||
|
recordDigitalEmployeeChatResult(request, {
|
||||||
|
success: false,
|
||||||
|
code: "5000",
|
||||||
|
message: "Agent not initialized",
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
code: "5000",
|
code: "5000",
|
||||||
message: "Agent not initialized",
|
message: "Agent not initialized",
|
||||||
@@ -32,7 +47,17 @@ export function registerComputerHandlers(): void {
|
|||||||
success: false,
|
success: false,
|
||||||
} as HttpResult;
|
} as HttpResult;
|
||||||
}
|
}
|
||||||
return acpEngine.chat(request);
|
const result = await acpEngine.chat(request);
|
||||||
|
recordDigitalEmployeeChatResult(request, {
|
||||||
|
success: result.success,
|
||||||
|
code: result.code,
|
||||||
|
message: result.message,
|
||||||
|
project_id: result.data?.project_id,
|
||||||
|
session_id: result.data?.session_id,
|
||||||
|
request_id: result.data?.request_id || request.request_id,
|
||||||
|
engine: acpEngine.engineName,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ import { firstTokenTrace } from "./engines/perf/firstTokenTrace";
|
|||||||
import { checkFileServerHealth } from "./packages/fileServerHealth";
|
import { checkFileServerHealth } from "./packages/fileServerHealth";
|
||||||
import { LOCALHOST_HOSTNAME } from "./constants";
|
import { LOCALHOST_HOSTNAME } from "./constants";
|
||||||
import { getConfiguredPorts } from "./startupPorts";
|
import { getConfiguredPorts } from "./startupPorts";
|
||||||
|
import {
|
||||||
|
recordDigitalEmployeeChatReceived,
|
||||||
|
recordDigitalEmployeeChatResult,
|
||||||
|
} from "./digitalEmployee/stateService";
|
||||||
import type {
|
import type {
|
||||||
ComputerChatRequest,
|
ComputerChatRequest,
|
||||||
HttpResult,
|
HttpResult,
|
||||||
@@ -406,6 +410,7 @@ async function handleRequest(
|
|||||||
let t1: number, t2: number, t3: number, t4: number;
|
let t1: number, t2: number, t3: number, t4: number;
|
||||||
|
|
||||||
const body = (await parseBody(req)) as ComputerChatRequest;
|
const body = (await parseBody(req)) as ComputerChatRequest;
|
||||||
|
recordDigitalEmployeeChatReceived(body);
|
||||||
t1 = Date.now();
|
t1 = Date.now();
|
||||||
firstTokenTrace.trace(
|
firstTokenTrace.trace(
|
||||||
"chat.received",
|
"chat.received",
|
||||||
@@ -515,6 +520,11 @@ async function handleRequest(
|
|||||||
try {
|
try {
|
||||||
acpEngine = await agentService.ensureEngineForRequest(body);
|
acpEngine = await agentService.ensureEngineForRequest(body);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
recordDigitalEmployeeChatResult(body, {
|
||||||
|
success: false,
|
||||||
|
code: "5000",
|
||||||
|
message: err.message || "Engine switch failed",
|
||||||
|
});
|
||||||
log.error("❌ [HTTP] Engine switch failed:", err);
|
log.error("❌ [HTTP] Engine switch failed:", err);
|
||||||
firstTokenTrace.trace(
|
firstTokenTrace.trace(
|
||||||
"chat.failed",
|
"chat.failed",
|
||||||
@@ -549,6 +559,11 @@ async function handleRequest(
|
|||||||
getPerfLogger().info(`[PERF] /chat.ensureEngine: ${t3 - t2_5}ms`);
|
getPerfLogger().info(`[PERF] /chat.ensureEngine: ${t3 - t2_5}ms`);
|
||||||
|
|
||||||
if (!acpEngine) {
|
if (!acpEngine) {
|
||||||
|
recordDigitalEmployeeChatResult(body, {
|
||||||
|
success: false,
|
||||||
|
code: "5000",
|
||||||
|
message: "Agent not initialized",
|
||||||
|
});
|
||||||
log.error("❌ [HTTP] Agent not initialized");
|
log.error("❌ [HTTP] Agent not initialized");
|
||||||
sendJson(res, 200, httpError("5000", "Agent not initialized"));
|
sendJson(res, 200, httpError("5000", "Agent not initialized"));
|
||||||
return;
|
return;
|
||||||
@@ -556,6 +571,15 @@ async function handleRequest(
|
|||||||
|
|
||||||
// chat() 已返回 HttpResult<ComputerChatResponse> 格式
|
// chat() 已返回 HttpResult<ComputerChatResponse> 格式
|
||||||
const result = await acpEngine.chat(body);
|
const result = await acpEngine.chat(body);
|
||||||
|
recordDigitalEmployeeChatResult(body, {
|
||||||
|
success: result.success,
|
||||||
|
code: result.code,
|
||||||
|
message: result.message,
|
||||||
|
project_id: result.data?.project_id,
|
||||||
|
session_id: result.data?.session_id,
|
||||||
|
request_id: result.data?.request_id || body.request_id,
|
||||||
|
engine: acpEngine.engineName,
|
||||||
|
});
|
||||||
t4 = Date.now();
|
t4 = Date.now();
|
||||||
firstTokenTrace.trace(
|
firstTokenTrace.trace(
|
||||||
result.success ? "chat.response.sent" : "chat.failed",
|
result.success ? "chat.response.sent" : "chat.failed",
|
||||||
|
|||||||
@@ -48,6 +48,25 @@ export interface DigitalEmployeeState {
|
|||||||
pendingSyncCount?: number;
|
pendingSyncCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DigitalEmployeeChatRequestRecord {
|
||||||
|
user_id?: string;
|
||||||
|
project_id?: string;
|
||||||
|
prompt?: string;
|
||||||
|
session_id?: string;
|
||||||
|
request_id?: string;
|
||||||
|
model_provider?: { provider?: string; model?: string; default_model?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DigitalEmployeeChatResultRecord {
|
||||||
|
success: boolean;
|
||||||
|
code?: string;
|
||||||
|
message?: string;
|
||||||
|
project_id?: string;
|
||||||
|
session_id?: string;
|
||||||
|
request_id?: string;
|
||||||
|
engine?: string;
|
||||||
|
}
|
||||||
|
|
||||||
function defaultState(): DigitalEmployeeState {
|
function defaultState(): DigitalEmployeeState {
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -96,6 +115,121 @@ export function recordDigitalEmployeeSnapshot(
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function recordDigitalEmployeeChatReceived(
|
||||||
|
request: DigitalEmployeeChatRequestRecord,
|
||||||
|
): { planId: string; taskId: string; runId: string } {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const ids = chatIds(request);
|
||||||
|
const title = chatTitle(request);
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
if (!db) return ids;
|
||||||
|
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
upsertPlan({
|
||||||
|
id: ids.planId,
|
||||||
|
title: `远程任务:${title}`,
|
||||||
|
objective: request.prompt || request.project_id || "管理端下发任务",
|
||||||
|
status: "running",
|
||||||
|
payload: request,
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
upsertTask({
|
||||||
|
id: ids.taskId,
|
||||||
|
planId: ids.planId,
|
||||||
|
title,
|
||||||
|
status: "running",
|
||||||
|
assignedSkillId: "qimingclaw-computer-control",
|
||||||
|
payload: request,
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
upsertRun({
|
||||||
|
id: ids.runId,
|
||||||
|
planId: ids.planId,
|
||||||
|
taskId: ids.taskId,
|
||||||
|
status: "running",
|
||||||
|
payload: { request },
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
insertEvent(
|
||||||
|
{
|
||||||
|
event_id: `${ids.runId}:received`,
|
||||||
|
kind: "computer_chat_received",
|
||||||
|
occurred_at: now,
|
||||||
|
message: `收到管理端任务:${title}`,
|
||||||
|
plan_id: ids.planId,
|
||||||
|
task_id: ids.taskId,
|
||||||
|
},
|
||||||
|
ids.runId,
|
||||||
|
{ request },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordDigitalEmployeeChatResult(
|
||||||
|
request: DigitalEmployeeChatRequestRecord,
|
||||||
|
result: DigitalEmployeeChatResultRecord,
|
||||||
|
): void {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const ids = chatIds({
|
||||||
|
...request,
|
||||||
|
request_id: request.request_id || result.request_id,
|
||||||
|
session_id: request.session_id || result.session_id,
|
||||||
|
project_id: request.project_id || result.project_id,
|
||||||
|
});
|
||||||
|
const status = result.success ? "completed" : "failed";
|
||||||
|
const title = chatTitle(request);
|
||||||
|
const db = getDb();
|
||||||
|
if (!db) return;
|
||||||
|
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
upsertPlan({
|
||||||
|
id: ids.planId,
|
||||||
|
title: `远程任务:${title}`,
|
||||||
|
objective: request.prompt || request.project_id || "管理端下发任务",
|
||||||
|
status,
|
||||||
|
payload: { request, result },
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
upsertTask({
|
||||||
|
id: ids.taskId,
|
||||||
|
planId: ids.planId,
|
||||||
|
title,
|
||||||
|
status,
|
||||||
|
assignedSkillId: "qimingclaw-computer-control",
|
||||||
|
payload: { request, result },
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
upsertRun({
|
||||||
|
id: ids.runId,
|
||||||
|
planId: ids.planId,
|
||||||
|
taskId: ids.taskId,
|
||||||
|
status,
|
||||||
|
payload: { request, result },
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
finishRun(ids.runId, status, now);
|
||||||
|
insertEvent(
|
||||||
|
{
|
||||||
|
event_id: `${ids.runId}:${status}`,
|
||||||
|
kind: result.success ? "computer_chat_completed" : "computer_chat_failed",
|
||||||
|
occurred_at: now,
|
||||||
|
message: result.success
|
||||||
|
? `管理端任务完成:session=${result.session_id || request.session_id || "unknown"}`
|
||||||
|
: `管理端任务失败:${result.message || result.code || "unknown error"}`,
|
||||||
|
plan_id: ids.planId,
|
||||||
|
task_id: ids.taskId,
|
||||||
|
},
|
||||||
|
ids.runId,
|
||||||
|
{ request, result },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
}
|
||||||
|
|
||||||
function persistSnapshotTables(
|
function persistSnapshotTables(
|
||||||
snapshot: DigitalEmployeeSnapshot,
|
snapshot: DigitalEmployeeSnapshot,
|
||||||
event: DigitalEmployeeStateEvent | null,
|
event: DigitalEmployeeStateEvent | null,
|
||||||
@@ -315,6 +449,16 @@ function upsertRun(input: {
|
|||||||
markOutbox("run", input.id, "upsert", input, input.now);
|
markOutbox("run", input.id, "upsert", input, input.now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function finishRun(runId: string, status: string, now: string): void {
|
||||||
|
const db = getDb();
|
||||||
|
if (!db) return;
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE digital_runs
|
||||||
|
SET status = ?, finished_at = ?, updated_at = ?, sync_status = 'pending'
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(status, now, now, runId);
|
||||||
|
}
|
||||||
|
|
||||||
function insertEvent(
|
function insertEvent(
|
||||||
event: DigitalEmployeeStateEvent,
|
event: DigitalEmployeeStateEvent,
|
||||||
runId: string,
|
runId: string,
|
||||||
@@ -340,6 +484,29 @@ function insertEvent(
|
|||||||
markOutbox("event", event.event_id, "insert", event, event.occurred_at);
|
markOutbox("event", event.event_id, "insert", event, event.occurred_at);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chatIds(request: DigitalEmployeeChatRequestRecord): {
|
||||||
|
planId: string;
|
||||||
|
taskId: string;
|
||||||
|
runId: string;
|
||||||
|
} {
|
||||||
|
const base = safeId(request.request_id || request.session_id || request.project_id || `${Date.now()}`);
|
||||||
|
return {
|
||||||
|
planId: `computer-chat-plan-${base}`,
|
||||||
|
taskId: `computer-chat-task-${base}`,
|
||||||
|
runId: `computer-chat-run-${base}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatTitle(request: DigitalEmployeeChatRequestRecord): string {
|
||||||
|
const prompt = (request.prompt || "").replace(/\s+/g, " ").trim();
|
||||||
|
if (prompt) return prompt.length > 48 ? `${prompt.slice(0, 48)}...` : prompt;
|
||||||
|
return request.project_id ? `项目 ${request.project_id}` : "管理端下发任务";
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeId(value: string): string {
|
||||||
|
return value.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
function eventFromSnapshot(
|
function eventFromSnapshot(
|
||||||
snapshot: DigitalEmployeeSnapshot,
|
snapshot: DigitalEmployeeSnapshot,
|
||||||
previous: DigitalEmployeeSnapshot | null,
|
previous: DigitalEmployeeSnapshot | null,
|
||||||
|
|||||||
@@ -350,6 +350,35 @@ digitalEmployee:getState
|
|||||||
- 在 computer server 的 `/computer/chat` 入口创建 Task / Run。
|
- 在 computer server 的 `/computer/chat` 入口创建 Task / Run。
|
||||||
- 在 ACP event forwarder 中写入 Run/Event。
|
- 在 ACP event forwarder 中写入 Run/Event。
|
||||||
|
|
||||||
|
## Phase 5:管理端任务入口落地(已开始)
|
||||||
|
|
||||||
|
目标:管理端或后端通过 `/computer/chat` 下发任务时,qimingclaw 本地数字员工状态可以生成真实任务记录。
|
||||||
|
|
||||||
|
当前 qimingclaw 已接入:
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/agent-electron-client/src/main/services/computerServer.ts
|
||||||
|
crates/agent-electron-client/src/main/ipc/computerHandlers.ts
|
||||||
|
crates/agent-electron-client/src/main/services/digitalEmployee/stateService.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
记录时点:
|
||||||
|
|
||||||
|
- 收到 chat 请求:创建/更新 Plan、Task、Run,写入 `computer_chat_received`。
|
||||||
|
- chat 成功返回:Run 标记 completed,写入 `computer_chat_completed`。
|
||||||
|
- chat 失败返回:Run 标记 failed,写入 `computer_chat_failed`。
|
||||||
|
|
||||||
|
当前联动意义:
|
||||||
|
|
||||||
|
- 管理端任务进入客户端后,本地 SQLite 会立即有可追溯记录。
|
||||||
|
- 对应 Plan / Task / Run / Event 均进入 `digital_sync_outbox`。
|
||||||
|
- 后续管理端 sync API 确定后,可将 outbox 推送到管理端并回填 `remote_id`。
|
||||||
|
|
||||||
|
下一步:
|
||||||
|
|
||||||
|
- 接 ACP event forwarder,把 token、tool、step、artifact 写入 Run/Event/Artifact。
|
||||||
|
- 增加 sync worker,根据 `step1_config.serverHost` 和登录 token 推送 outbox。
|
||||||
|
|
||||||
## 管理端联动原则
|
## 管理端联动原则
|
||||||
|
|
||||||
数字员工状态是本地优先,但不能成为本地孤岛。
|
数字员工状态是本地优先,但不能成为本地孤岛。
|
||||||
|
|||||||
Reference in New Issue
Block a user