614 lines
16 KiB
TypeScript
614 lines
16 KiB
TypeScript
import { getDb, 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[];
|
||
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;
|
||
}
|
||
|
||
export interface DigitalEmployeeRuntimeEventRecord {
|
||
kind: string;
|
||
message?: string;
|
||
request_id?: string;
|
||
session_id?: string;
|
||
project_id?: string;
|
||
status?: "running" | "completed" | "failed" | "pending";
|
||
payload?: unknown;
|
||
}
|
||
|
||
function defaultState(): DigitalEmployeeState {
|
||
return {
|
||
version: 1,
|
||
updatedAt: null,
|
||
lastSnapshot: null,
|
||
events: [],
|
||
};
|
||
}
|
||
|
||
export function readDigitalEmployeeState(): DigitalEmployeeState {
|
||
const state = readSetting(STATE_KEY);
|
||
const pendingSyncCount = countPendingSyncItems();
|
||
if (!state || typeof state !== "object") {
|
||
return { ...defaultState(), pendingSyncCount };
|
||
}
|
||
return {
|
||
...defaultState(),
|
||
...(state as Partial<DigitalEmployeeState>),
|
||
version: 1,
|
||
pendingSyncCount,
|
||
};
|
||
}
|
||
|
||
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);
|
||
persistSnapshotTables(snapshot, event);
|
||
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();
|
||
}
|
||
|
||
export function recordDigitalEmployeeRuntimeEvent(
|
||
event: DigitalEmployeeRuntimeEventRecord,
|
||
): void {
|
||
const now = new Date().toISOString();
|
||
const ids = chatIds({
|
||
request_id: event.request_id,
|
||
session_id: event.session_id,
|
||
project_id: event.project_id,
|
||
prompt: event.message,
|
||
});
|
||
const status = event.status ?? "running";
|
||
const title = event.message || event.kind;
|
||
const db = getDb();
|
||
if (!db) return;
|
||
|
||
const tx = db.transaction(() => {
|
||
upsertPlan({
|
||
id: ids.planId,
|
||
title: `运行事件:${title}`,
|
||
objective: title,
|
||
status,
|
||
payload: event.payload ?? event,
|
||
now,
|
||
});
|
||
upsertTask({
|
||
id: ids.taskId,
|
||
planId: ids.planId,
|
||
title,
|
||
status,
|
||
assignedSkillId: "qimingclaw-acp-session",
|
||
payload: event.payload ?? event,
|
||
now,
|
||
});
|
||
upsertRun({
|
||
id: ids.runId,
|
||
planId: ids.planId,
|
||
taskId: ids.taskId,
|
||
status,
|
||
payload: event.payload ?? event,
|
||
now,
|
||
});
|
||
if (status === "completed" || status === "failed") {
|
||
finishRun(ids.runId, status, now);
|
||
}
|
||
insertEvent(
|
||
{
|
||
event_id: `${ids.runId}:${event.kind}:${Date.now()}`,
|
||
kind: event.kind,
|
||
occurred_at: now,
|
||
message: title,
|
||
plan_id: ids.planId,
|
||
task_id: ids.taskId,
|
||
},
|
||
ids.runId,
|
||
event.payload ?? event,
|
||
);
|
||
});
|
||
tx();
|
||
}
|
||
|
||
function persistSnapshotTables(
|
||
snapshot: DigitalEmployeeSnapshot,
|
||
event: DigitalEmployeeStateEvent | null,
|
||
): void {
|
||
const db = getDb();
|
||
if (!db) return;
|
||
|
||
const now = snapshot.generatedAt;
|
||
const planStatus = snapshot.services.agent?.running || snapshot.services.mcp?.running
|
||
? "running"
|
||
: "pending";
|
||
const taskStatus = snapshot.services.agent?.running ? "running" : "pending";
|
||
const runStatus = planStatus === "running" ? "running" : "pending";
|
||
|
||
const planPayload = {
|
||
services: snapshot.services,
|
||
sessions: snapshot.sessions,
|
||
};
|
||
const taskPayload = {
|
||
service: "agent",
|
||
agent: snapshot.services.agent,
|
||
mcp: snapshot.services.mcp,
|
||
};
|
||
const runPayload = {
|
||
snapshot,
|
||
};
|
||
|
||
const tx = db.transaction(() => {
|
||
upsertPlan({
|
||
id: "qimingclaw-client-readiness",
|
||
title: "客户端运行状态巡检",
|
||
objective: "确认 qimingclaw 客户端、Agent、MCP 和远程接入能力是否处于可执行状态。",
|
||
status: planStatus,
|
||
payload: planPayload,
|
||
now,
|
||
});
|
||
upsertTask({
|
||
id: "task-agent-status",
|
||
planId: "qimingclaw-client-readiness",
|
||
title: "读取 Agent 服务状态",
|
||
status: taskStatus,
|
||
assignedSkillId: "qimingclaw-acp-session",
|
||
payload: taskPayload,
|
||
now,
|
||
});
|
||
upsertRun({
|
||
id: "run-service-snapshot",
|
||
planId: "qimingclaw-client-readiness",
|
||
taskId: "task-agent-status",
|
||
status: runStatus,
|
||
payload: runPayload,
|
||
now,
|
||
});
|
||
if (event) {
|
||
insertEvent(event, "run-service-snapshot", { snapshot });
|
||
}
|
||
});
|
||
|
||
tx();
|
||
}
|
||
|
||
function stringify(value: unknown): string {
|
||
return JSON.stringify(value ?? {});
|
||
}
|
||
|
||
function countPendingSyncItems(): number {
|
||
const db = getDb();
|
||
if (!db) return 0;
|
||
const row = db.prepare(`
|
||
SELECT COUNT(*) AS count FROM digital_sync_outbox WHERE status = 'pending'
|
||
`).get() as { count: number } | undefined;
|
||
return row?.count ?? 0;
|
||
}
|
||
|
||
function syncOutboxId(entityType: string, entityId: string, operation = "upsert"): string {
|
||
return `${entityType}:${operation}:${entityId}`;
|
||
}
|
||
|
||
function markOutbox(
|
||
entityType: string,
|
||
entityId: string,
|
||
operation: string,
|
||
payload: unknown,
|
||
now: string,
|
||
): void {
|
||
const db = getDb();
|
||
if (!db) return;
|
||
db.prepare(`
|
||
INSERT INTO digital_sync_outbox (
|
||
id, entity_type, entity_id, operation, payload, status, attempts, created_at, updated_at
|
||
) VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?)
|
||
ON CONFLICT(id) DO UPDATE SET
|
||
payload = excluded.payload,
|
||
status = 'pending',
|
||
updated_at = excluded.updated_at,
|
||
error = NULL
|
||
`).run(
|
||
syncOutboxId(entityType, entityId, operation),
|
||
entityType,
|
||
entityId,
|
||
operation,
|
||
stringify(payload),
|
||
now,
|
||
now,
|
||
);
|
||
}
|
||
|
||
function upsertPlan(input: {
|
||
id: string;
|
||
title: string;
|
||
objective: string;
|
||
status: string;
|
||
payload: unknown;
|
||
now: string;
|
||
}): void {
|
||
const db = getDb();
|
||
if (!db) return;
|
||
db.prepare(`
|
||
INSERT INTO digital_plans (
|
||
id, title, objective, status, source, payload, sync_status, created_at, updated_at
|
||
) VALUES (?, ?, ?, ?, 'qimingclaw', ?, 'pending', ?, ?)
|
||
ON CONFLICT(id) DO UPDATE SET
|
||
title = excluded.title,
|
||
objective = excluded.objective,
|
||
status = excluded.status,
|
||
payload = excluded.payload,
|
||
sync_status = CASE
|
||
WHEN digital_plans.sync_status = 'synced' THEN 'pending'
|
||
ELSE digital_plans.sync_status
|
||
END,
|
||
updated_at = excluded.updated_at
|
||
`).run(
|
||
input.id,
|
||
input.title,
|
||
input.objective,
|
||
input.status,
|
||
stringify(input.payload),
|
||
input.now,
|
||
input.now,
|
||
);
|
||
markOutbox("plan", input.id, "upsert", input, input.now);
|
||
}
|
||
|
||
function upsertTask(input: {
|
||
id: string;
|
||
planId: string;
|
||
title: string;
|
||
status: string;
|
||
assignedSkillId: string;
|
||
payload: unknown;
|
||
now: string;
|
||
}): void {
|
||
const db = getDb();
|
||
if (!db) return;
|
||
db.prepare(`
|
||
INSERT INTO digital_tasks (
|
||
id, plan_id, title, status, assigned_skill_id, payload, sync_status, created_at, updated_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?)
|
||
ON CONFLICT(id) DO UPDATE SET
|
||
plan_id = excluded.plan_id,
|
||
title = excluded.title,
|
||
status = excluded.status,
|
||
assigned_skill_id = excluded.assigned_skill_id,
|
||
payload = excluded.payload,
|
||
sync_status = CASE
|
||
WHEN digital_tasks.sync_status = 'synced' THEN 'pending'
|
||
ELSE digital_tasks.sync_status
|
||
END,
|
||
updated_at = excluded.updated_at
|
||
`).run(
|
||
input.id,
|
||
input.planId,
|
||
input.title,
|
||
input.status,
|
||
input.assignedSkillId,
|
||
stringify(input.payload),
|
||
input.now,
|
||
input.now,
|
||
);
|
||
markOutbox("task", input.id, "upsert", input, input.now);
|
||
}
|
||
|
||
function upsertRun(input: {
|
||
id: string;
|
||
planId: string;
|
||
taskId: string;
|
||
status: string;
|
||
payload: unknown;
|
||
now: string;
|
||
}): void {
|
||
const db = getDb();
|
||
if (!db) return;
|
||
db.prepare(`
|
||
INSERT INTO digital_runs (
|
||
id, plan_id, task_id, status, started_at, payload, sync_status, created_at, updated_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?)
|
||
ON CONFLICT(id) DO UPDATE SET
|
||
plan_id = excluded.plan_id,
|
||
task_id = excluded.task_id,
|
||
status = excluded.status,
|
||
payload = excluded.payload,
|
||
sync_status = CASE
|
||
WHEN digital_runs.sync_status = 'synced' THEN 'pending'
|
||
ELSE digital_runs.sync_status
|
||
END,
|
||
updated_at = excluded.updated_at
|
||
`).run(
|
||
input.id,
|
||
input.planId,
|
||
input.taskId,
|
||
input.status,
|
||
input.now,
|
||
stringify(input.payload),
|
||
input.now,
|
||
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(
|
||
event: DigitalEmployeeStateEvent,
|
||
runId: string,
|
||
payload: unknown,
|
||
): void {
|
||
const db = getDb();
|
||
if (!db) return;
|
||
db.prepare(`
|
||
INSERT OR IGNORE INTO digital_events (
|
||
id, plan_id, task_id, run_id, kind, message, payload, sync_status, occurred_at, created_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)
|
||
`).run(
|
||
event.event_id,
|
||
event.plan_id,
|
||
event.task_id,
|
||
runId,
|
||
event.kind,
|
||
event.message,
|
||
stringify(payload),
|
||
event.occurred_at,
|
||
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(
|
||
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(";");
|
||
}
|