feat(client): project digital employee runtime into UI
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { getDb, readSetting, writeSetting } from "../../db";
|
||||
|
||||
const STATE_KEY = "digital_employee_state_v1";
|
||||
const UI_STATE_KEY = "digital_employee_ui_state_v1";
|
||||
const MAX_EVENTS = 200;
|
||||
|
||||
export interface DigitalEmployeeServiceStatus {
|
||||
@@ -31,6 +32,131 @@ export interface DigitalEmployeeSnapshot {
|
||||
sessions: DigitalEmployeeSessionSummary;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeePlanRecord {
|
||||
id: string;
|
||||
remoteId?: string | null;
|
||||
title: string;
|
||||
objective?: string | null;
|
||||
status: string;
|
||||
source: string;
|
||||
payload: unknown;
|
||||
syncStatus: string;
|
||||
lastSyncedAt?: string | null;
|
||||
syncError?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeTaskRecord {
|
||||
id: string;
|
||||
remoteId?: string | null;
|
||||
planId?: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
assignedSkillId?: string | null;
|
||||
payload: unknown;
|
||||
syncStatus: string;
|
||||
lastSyncedAt?: string | null;
|
||||
syncError?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeRunRecord {
|
||||
id: string;
|
||||
remoteId?: string | null;
|
||||
planId?: string | null;
|
||||
taskId?: string | null;
|
||||
status: string;
|
||||
startedAt?: string | null;
|
||||
finishedAt?: string | null;
|
||||
payload: unknown;
|
||||
syncStatus: string;
|
||||
lastSyncedAt?: string | null;
|
||||
syncError?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeEventRecord {
|
||||
id: string;
|
||||
remoteId?: string | null;
|
||||
planId?: string | null;
|
||||
taskId?: string | null;
|
||||
runId?: string | null;
|
||||
kind: string;
|
||||
message: string;
|
||||
payload: unknown;
|
||||
syncStatus: string;
|
||||
lastSyncedAt?: string | null;
|
||||
syncError?: string | null;
|
||||
occurredAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeArtifactRecord {
|
||||
id: string;
|
||||
remoteId?: string | null;
|
||||
planId?: string | null;
|
||||
taskId?: string | null;
|
||||
runId?: string | null;
|
||||
kind: string;
|
||||
name?: string | null;
|
||||
uri?: string | null;
|
||||
payload: unknown;
|
||||
syncStatus: string;
|
||||
lastSyncedAt?: string | null;
|
||||
syncError?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeApprovalRecord {
|
||||
id: string;
|
||||
remoteId?: string | null;
|
||||
planId?: string | null;
|
||||
taskId?: string | null;
|
||||
runId?: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
payload: unknown;
|
||||
syncStatus: string;
|
||||
lastSyncedAt?: string | null;
|
||||
syncError?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeRuntimeRecords {
|
||||
generatedAt: string;
|
||||
plans: DigitalEmployeePlanRecord[];
|
||||
tasks: DigitalEmployeeTaskRecord[];
|
||||
runs: DigitalEmployeeRunRecord[];
|
||||
events: DigitalEmployeeEventRecord[];
|
||||
artifacts: DigitalEmployeeArtifactRecord[];
|
||||
approvals: DigitalEmployeeApprovalRecord[];
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeUiState {
|
||||
selectedDutyIdsByDate: Record<string, string[]>;
|
||||
confirmedDates: Record<string, string>;
|
||||
reportScheduleTime: string;
|
||||
reportGeneratedAtByDate: Record<string, string>;
|
||||
decisionResultsByDate: Record<string, Record<string, string>>;
|
||||
profile: {
|
||||
name: string;
|
||||
company: string;
|
||||
position: string;
|
||||
currentStatus: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeUiStateUpdate {
|
||||
state: DigitalEmployeeUiState;
|
||||
action?: string;
|
||||
date?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeStateEvent {
|
||||
event_id: string;
|
||||
kind: string;
|
||||
@@ -86,6 +212,102 @@ function defaultState(): DigitalEmployeeState {
|
||||
};
|
||||
}
|
||||
|
||||
function defaultUiState(): DigitalEmployeeUiState {
|
||||
return {
|
||||
selectedDutyIdsByDate: {},
|
||||
confirmedDates: {},
|
||||
reportScheduleTime: "18:00",
|
||||
reportGeneratedAtByDate: {},
|
||||
decisionResultsByDate: {},
|
||||
profile: {
|
||||
name: "飞天数字员工",
|
||||
company: "qimingclaw 客户端",
|
||||
position: "客户端任务执行员",
|
||||
currentStatus: "working",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStringMap(value: unknown): Record<string, string> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.filter((entry): entry is [string, string] =>
|
||||
typeof entry[0] === "string" && typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeStringArrayMap(value: unknown): Record<string, string[]> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([key, list]) => [
|
||||
key,
|
||||
Array.isArray(list)
|
||||
? list.filter((item): item is string => typeof item === "string")
|
||||
: [],
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeNestedStringMap(value: unknown): Record<string, Record<string, string>> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([key, map]) => [
|
||||
key,
|
||||
normalizeStringMap(map),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeUiState(
|
||||
value: Partial<DigitalEmployeeUiState>,
|
||||
): DigitalEmployeeUiState {
|
||||
const fallback = defaultUiState();
|
||||
const profile: Partial<DigitalEmployeeUiState["profile"]> = value.profile || {};
|
||||
return {
|
||||
selectedDutyIdsByDate: normalizeStringArrayMap(value.selectedDutyIdsByDate),
|
||||
confirmedDates: normalizeStringMap(value.confirmedDates),
|
||||
reportScheduleTime: typeof value.reportScheduleTime === "string" && value.reportScheduleTime.trim()
|
||||
? value.reportScheduleTime
|
||||
: fallback.reportScheduleTime,
|
||||
reportGeneratedAtByDate: normalizeStringMap(value.reportGeneratedAtByDate),
|
||||
decisionResultsByDate: normalizeNestedStringMap(value.decisionResultsByDate),
|
||||
profile: {
|
||||
name: typeof profile.name === "string" && profile.name.trim()
|
||||
? profile.name
|
||||
: fallback.profile.name,
|
||||
company: typeof profile.company === "string" && profile.company.trim()
|
||||
? profile.company
|
||||
: fallback.profile.company,
|
||||
position: typeof profile.position === "string" && profile.position.trim()
|
||||
? profile.position
|
||||
: fallback.profile.position,
|
||||
currentStatus: typeof profile.currentStatus === "string" && profile.currentStatus.trim()
|
||||
? profile.currentStatus
|
||||
: fallback.profile.currentStatus,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function digitalEmployeeUiActionMessage(action: string, date?: string): string {
|
||||
const suffix = date ? `(${date})` : "";
|
||||
switch (action) {
|
||||
case "confirm_workday":
|
||||
return `数字员工今日任务已确认${suffix}`;
|
||||
case "update_profile":
|
||||
return "数字员工资料已更新";
|
||||
case "update_report_schedule":
|
||||
return `数字员工日报时间已更新${suffix}`;
|
||||
case "generate_daily_report":
|
||||
return `数字员工日报已生成${suffix}`;
|
||||
case "decide_approval":
|
||||
return `数字员工待决策事项已处理${suffix}`;
|
||||
default:
|
||||
return `数字员工页面状态已更新${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function readDigitalEmployeeState(): DigitalEmployeeState {
|
||||
const state = readSetting(STATE_KEY);
|
||||
const pendingSyncCount = countPendingSyncItems();
|
||||
@@ -104,6 +326,103 @@ export function writeDigitalEmployeeState(state: DigitalEmployeeState): void {
|
||||
writeSetting(STATE_KEY, state);
|
||||
}
|
||||
|
||||
export function readDigitalEmployeeUiState(): DigitalEmployeeUiState {
|
||||
const state = readSetting(UI_STATE_KEY);
|
||||
if (!state || typeof state !== "object") return defaultUiState();
|
||||
return normalizeUiState(state as Partial<DigitalEmployeeUiState>);
|
||||
}
|
||||
|
||||
export function saveDigitalEmployeeUiState(
|
||||
update: DigitalEmployeeUiStateUpdate,
|
||||
): DigitalEmployeeUiState {
|
||||
const state = normalizeUiState(update.state);
|
||||
writeSetting(UI_STATE_KEY, state);
|
||||
if (update.action) {
|
||||
recordDigitalEmployeeRuntimeEvent({
|
||||
kind: `digital_workday_${update.action}`,
|
||||
message: digitalEmployeeUiActionMessage(update.action, update.date),
|
||||
status: "completed",
|
||||
payload: {
|
||||
action: update.action,
|
||||
date: update.date,
|
||||
metadata: update.metadata,
|
||||
state,
|
||||
},
|
||||
});
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export function readDigitalEmployeeRuntimeRecords(
|
||||
limit = 120,
|
||||
): DigitalEmployeeRuntimeRecords {
|
||||
const db = getDb();
|
||||
if (!db) {
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
plans: [],
|
||||
tasks: [],
|
||||
runs: [],
|
||||
events: [],
|
||||
artifacts: [],
|
||||
approvals: [],
|
||||
};
|
||||
}
|
||||
const safeLimit = Math.max(1, Math.min(Math.floor(limit), 300));
|
||||
const plans = db.prepare(`
|
||||
SELECT id, remote_id, title, objective, status, source, payload, sync_status,
|
||||
last_synced_at, sync_error, created_at, updated_at
|
||||
FROM digital_plans
|
||||
ORDER BY updated_at DESC, created_at DESC
|
||||
LIMIT ?
|
||||
`).all(safeLimit) as Array<Record<string, unknown>>;
|
||||
const tasks = db.prepare(`
|
||||
SELECT id, remote_id, plan_id, title, status, assigned_skill_id, payload,
|
||||
sync_status, last_synced_at, sync_error, created_at, updated_at
|
||||
FROM digital_tasks
|
||||
ORDER BY updated_at DESC, created_at DESC
|
||||
LIMIT ?
|
||||
`).all(safeLimit) as Array<Record<string, unknown>>;
|
||||
const runs = db.prepare(`
|
||||
SELECT id, remote_id, plan_id, task_id, status, started_at, finished_at,
|
||||
payload, sync_status, last_synced_at, sync_error, created_at, updated_at
|
||||
FROM digital_runs
|
||||
ORDER BY updated_at DESC, created_at DESC
|
||||
LIMIT ?
|
||||
`).all(safeLimit) as Array<Record<string, unknown>>;
|
||||
const events = db.prepare(`
|
||||
SELECT id, remote_id, plan_id, task_id, run_id, kind, message, payload,
|
||||
sync_status, last_synced_at, sync_error, occurred_at, created_at
|
||||
FROM digital_events
|
||||
ORDER BY occurred_at DESC, created_at DESC
|
||||
LIMIT ?
|
||||
`).all(safeLimit) as Array<Record<string, unknown>>;
|
||||
const artifacts = db.prepare(`
|
||||
SELECT id, remote_id, plan_id, task_id, run_id, kind, name, uri, payload,
|
||||
sync_status, last_synced_at, sync_error, created_at
|
||||
FROM digital_artifacts
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
`).all(safeLimit) as Array<Record<string, unknown>>;
|
||||
const approvals = db.prepare(`
|
||||
SELECT id, remote_id, plan_id, task_id, run_id, title, status, payload,
|
||||
sync_status, last_synced_at, sync_error, created_at, updated_at
|
||||
FROM digital_approvals
|
||||
ORDER BY updated_at DESC, created_at DESC
|
||||
LIMIT ?
|
||||
`).all(safeLimit) as Array<Record<string, unknown>>;
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
plans: plans.map(toPlanRecord),
|
||||
tasks: tasks.map(toTaskRecord),
|
||||
runs: runs.map(toRunRecord),
|
||||
events: events.map(toEventRecord),
|
||||
artifacts: artifacts.map(toArtifactRecord),
|
||||
approvals: approvals.map(toApprovalRecord),
|
||||
};
|
||||
}
|
||||
|
||||
export function recordDigitalEmployeeSnapshot(
|
||||
snapshot: DigitalEmployeeSnapshot,
|
||||
): DigitalEmployeeState {
|
||||
@@ -161,6 +480,7 @@ export function recordDigitalEmployeeChatReceived(
|
||||
payload: { request },
|
||||
now,
|
||||
});
|
||||
persistPayloadArtifactsAndApprovals(ids, request, now);
|
||||
insertEvent(
|
||||
{
|
||||
event_id: `${ids.runId}:received`,
|
||||
@@ -222,6 +542,7 @@ export function recordDigitalEmployeeChatResult(
|
||||
now,
|
||||
});
|
||||
finishRun(ids.runId, status, now);
|
||||
persistPayloadArtifactsAndApprovals(ids, { request, result }, now);
|
||||
insertEvent(
|
||||
{
|
||||
event_id: `${ids.runId}:${status}`,
|
||||
@@ -284,6 +605,7 @@ export function recordDigitalEmployeeRuntimeEvent(
|
||||
if (status === "completed" || status === "failed") {
|
||||
finishRun(ids.runId, status, now);
|
||||
}
|
||||
persistPayloadArtifactsAndApprovals(ids, event.payload ?? event, now);
|
||||
insertEvent(
|
||||
{
|
||||
event_id: `${ids.runId}:${event.kind}:${Date.now()}`,
|
||||
@@ -365,6 +687,138 @@ function stringify(value: unknown): string {
|
||||
return JSON.stringify(value ?? {});
|
||||
}
|
||||
|
||||
function stringField(row: Record<string, unknown>, key: string): string {
|
||||
const value = row[key];
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function optionalStringField(
|
||||
row: Record<string, unknown>,
|
||||
key: string,
|
||||
): string | null {
|
||||
const value = row[key];
|
||||
return typeof value === "string" && value.trim() ? value : null;
|
||||
}
|
||||
|
||||
function parseStoredPayload(value: unknown): unknown {
|
||||
if (typeof value !== "string" || !value.trim()) return {};
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function toPlanRecord(row: Record<string, unknown>): DigitalEmployeePlanRecord {
|
||||
return {
|
||||
id: stringField(row, "id"),
|
||||
remoteId: optionalStringField(row, "remote_id"),
|
||||
title: stringField(row, "title"),
|
||||
objective: optionalStringField(row, "objective"),
|
||||
status: stringField(row, "status"),
|
||||
source: stringField(row, "source"),
|
||||
payload: parseStoredPayload(row.payload),
|
||||
syncStatus: stringField(row, "sync_status"),
|
||||
lastSyncedAt: optionalStringField(row, "last_synced_at"),
|
||||
syncError: optionalStringField(row, "sync_error"),
|
||||
createdAt: stringField(row, "created_at"),
|
||||
updatedAt: stringField(row, "updated_at"),
|
||||
};
|
||||
}
|
||||
|
||||
function toTaskRecord(row: Record<string, unknown>): DigitalEmployeeTaskRecord {
|
||||
return {
|
||||
id: stringField(row, "id"),
|
||||
remoteId: optionalStringField(row, "remote_id"),
|
||||
planId: optionalStringField(row, "plan_id"),
|
||||
title: stringField(row, "title"),
|
||||
status: stringField(row, "status"),
|
||||
assignedSkillId: optionalStringField(row, "assigned_skill_id"),
|
||||
payload: parseStoredPayload(row.payload),
|
||||
syncStatus: stringField(row, "sync_status"),
|
||||
lastSyncedAt: optionalStringField(row, "last_synced_at"),
|
||||
syncError: optionalStringField(row, "sync_error"),
|
||||
createdAt: stringField(row, "created_at"),
|
||||
updatedAt: stringField(row, "updated_at"),
|
||||
};
|
||||
}
|
||||
|
||||
function toRunRecord(row: Record<string, unknown>): DigitalEmployeeRunRecord {
|
||||
return {
|
||||
id: stringField(row, "id"),
|
||||
remoteId: optionalStringField(row, "remote_id"),
|
||||
planId: optionalStringField(row, "plan_id"),
|
||||
taskId: optionalStringField(row, "task_id"),
|
||||
status: stringField(row, "status"),
|
||||
startedAt: optionalStringField(row, "started_at"),
|
||||
finishedAt: optionalStringField(row, "finished_at"),
|
||||
payload: parseStoredPayload(row.payload),
|
||||
syncStatus: stringField(row, "sync_status"),
|
||||
lastSyncedAt: optionalStringField(row, "last_synced_at"),
|
||||
syncError: optionalStringField(row, "sync_error"),
|
||||
createdAt: stringField(row, "created_at"),
|
||||
updatedAt: stringField(row, "updated_at"),
|
||||
};
|
||||
}
|
||||
|
||||
function toEventRecord(row: Record<string, unknown>): DigitalEmployeeEventRecord {
|
||||
return {
|
||||
id: stringField(row, "id"),
|
||||
remoteId: optionalStringField(row, "remote_id"),
|
||||
planId: optionalStringField(row, "plan_id"),
|
||||
taskId: optionalStringField(row, "task_id"),
|
||||
runId: optionalStringField(row, "run_id"),
|
||||
kind: stringField(row, "kind"),
|
||||
message: stringField(row, "message"),
|
||||
payload: parseStoredPayload(row.payload),
|
||||
syncStatus: stringField(row, "sync_status"),
|
||||
lastSyncedAt: optionalStringField(row, "last_synced_at"),
|
||||
syncError: optionalStringField(row, "sync_error"),
|
||||
occurredAt: stringField(row, "occurred_at"),
|
||||
createdAt: stringField(row, "created_at"),
|
||||
};
|
||||
}
|
||||
|
||||
function toArtifactRecord(
|
||||
row: Record<string, unknown>,
|
||||
): DigitalEmployeeArtifactRecord {
|
||||
return {
|
||||
id: stringField(row, "id"),
|
||||
remoteId: optionalStringField(row, "remote_id"),
|
||||
planId: optionalStringField(row, "plan_id"),
|
||||
taskId: optionalStringField(row, "task_id"),
|
||||
runId: optionalStringField(row, "run_id"),
|
||||
kind: stringField(row, "kind"),
|
||||
name: optionalStringField(row, "name"),
|
||||
uri: optionalStringField(row, "uri"),
|
||||
payload: parseStoredPayload(row.payload),
|
||||
syncStatus: stringField(row, "sync_status"),
|
||||
lastSyncedAt: optionalStringField(row, "last_synced_at"),
|
||||
syncError: optionalStringField(row, "sync_error"),
|
||||
createdAt: stringField(row, "created_at"),
|
||||
};
|
||||
}
|
||||
|
||||
function toApprovalRecord(
|
||||
row: Record<string, unknown>,
|
||||
): DigitalEmployeeApprovalRecord {
|
||||
return {
|
||||
id: stringField(row, "id"),
|
||||
remoteId: optionalStringField(row, "remote_id"),
|
||||
planId: optionalStringField(row, "plan_id"),
|
||||
taskId: optionalStringField(row, "task_id"),
|
||||
runId: optionalStringField(row, "run_id"),
|
||||
title: stringField(row, "title"),
|
||||
status: stringField(row, "status"),
|
||||
payload: parseStoredPayload(row.payload),
|
||||
syncStatus: stringField(row, "sync_status"),
|
||||
lastSyncedAt: optionalStringField(row, "last_synced_at"),
|
||||
syncError: optionalStringField(row, "sync_error"),
|
||||
createdAt: stringField(row, "created_at"),
|
||||
updatedAt: stringField(row, "updated_at"),
|
||||
};
|
||||
}
|
||||
|
||||
function countPendingSyncItems(): number {
|
||||
const db = getDb();
|
||||
if (!db) return 0;
|
||||
@@ -529,6 +983,91 @@ function finishRun(runId: string, status: string, now: string): void {
|
||||
`).run(status, now, now, runId);
|
||||
}
|
||||
|
||||
function upsertArtifact(input: {
|
||||
id: string;
|
||||
planId: string;
|
||||
taskId: string;
|
||||
runId: string;
|
||||
kind: string;
|
||||
name: string | null;
|
||||
uri: string | null;
|
||||
payload: unknown;
|
||||
now: string;
|
||||
}): void {
|
||||
const db = getDb();
|
||||
if (!db) return;
|
||||
db.prepare(`
|
||||
INSERT INTO digital_artifacts (
|
||||
id, plan_id, task_id, run_id, kind, name, uri, payload, sync_status, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
plan_id = excluded.plan_id,
|
||||
task_id = excluded.task_id,
|
||||
run_id = excluded.run_id,
|
||||
kind = excluded.kind,
|
||||
name = excluded.name,
|
||||
uri = excluded.uri,
|
||||
payload = excluded.payload,
|
||||
sync_status = CASE
|
||||
WHEN digital_artifacts.sync_status = 'synced' THEN 'pending'
|
||||
ELSE digital_artifacts.sync_status
|
||||
END
|
||||
`).run(
|
||||
input.id,
|
||||
input.planId,
|
||||
input.taskId,
|
||||
input.runId,
|
||||
input.kind,
|
||||
input.name,
|
||||
input.uri,
|
||||
stringify(input.payload),
|
||||
input.now,
|
||||
);
|
||||
markOutbox("artifact", input.id, "upsert", input, input.now);
|
||||
}
|
||||
|
||||
function upsertApproval(input: {
|
||||
id: string;
|
||||
planId: string;
|
||||
taskId: string;
|
||||
runId: string;
|
||||
title: string;
|
||||
status: string;
|
||||
payload: unknown;
|
||||
now: string;
|
||||
}): void {
|
||||
const db = getDb();
|
||||
if (!db) return;
|
||||
db.prepare(`
|
||||
INSERT INTO digital_approvals (
|
||||
id, plan_id, task_id, run_id, title, status, 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,
|
||||
run_id = excluded.run_id,
|
||||
title = excluded.title,
|
||||
status = excluded.status,
|
||||
payload = excluded.payload,
|
||||
sync_status = CASE
|
||||
WHEN digital_approvals.sync_status = 'synced' THEN 'pending'
|
||||
ELSE digital_approvals.sync_status
|
||||
END,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(
|
||||
input.id,
|
||||
input.planId,
|
||||
input.taskId,
|
||||
input.runId,
|
||||
input.title,
|
||||
input.status,
|
||||
stringify(input.payload),
|
||||
input.now,
|
||||
input.now,
|
||||
);
|
||||
markOutbox("approval", input.id, "upsert", input, input.now);
|
||||
}
|
||||
|
||||
function insertEvent(
|
||||
event: DigitalEmployeeStateEvent,
|
||||
runId: string,
|
||||
@@ -554,6 +1093,189 @@ function insertEvent(
|
||||
markOutbox("event", event.event_id, "insert", event, event.occurred_at);
|
||||
}
|
||||
|
||||
function persistPayloadArtifactsAndApprovals(
|
||||
ids: { planId: string; taskId: string; runId: string },
|
||||
payload: unknown,
|
||||
now: string,
|
||||
): void {
|
||||
const sources = payloadSources(payload);
|
||||
const artifacts = sources.flatMap((source) => extractArtifacts(source));
|
||||
const approvals = sources.flatMap((source) => extractApprovals(source));
|
||||
|
||||
dedupeByKey(artifacts, (artifact) => artifact.key).slice(0, 20).forEach((artifact, index) => {
|
||||
upsertArtifact({
|
||||
id: `${ids.runId}:artifact:${safeId(artifact.key || String(index + 1))}`,
|
||||
planId: ids.planId,
|
||||
taskId: ids.taskId,
|
||||
runId: ids.runId,
|
||||
kind: artifact.kind,
|
||||
name: artifact.name,
|
||||
uri: artifact.uri,
|
||||
payload: artifact.payload,
|
||||
now,
|
||||
});
|
||||
});
|
||||
|
||||
dedupeByKey(approvals, (approval) => approval.key).slice(0, 20).forEach((approval, index) => {
|
||||
upsertApproval({
|
||||
id: `${ids.runId}:approval:${safeId(approval.key || String(index + 1))}`,
|
||||
planId: ids.planId,
|
||||
taskId: ids.taskId,
|
||||
runId: ids.runId,
|
||||
title: approval.title,
|
||||
status: approval.status,
|
||||
payload: approval.payload,
|
||||
now,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function payloadSources(payload: unknown): unknown[] {
|
||||
const record = objectRecord(payload);
|
||||
if (!record) return [payload];
|
||||
return [
|
||||
payload,
|
||||
record.result,
|
||||
record.response,
|
||||
record.output,
|
||||
record.payload,
|
||||
].filter((item) => item !== undefined && item !== null);
|
||||
}
|
||||
|
||||
interface ExtractedArtifact {
|
||||
key: string;
|
||||
kind: string;
|
||||
name: string | null;
|
||||
uri: string | null;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
function extractArtifacts(payload: unknown): ExtractedArtifact[] {
|
||||
const record = objectRecord(payload);
|
||||
if (!record) return typeof payload === "string" && looksLikeArtifactUri(payload)
|
||||
? [artifactFromValue(payload, 0)]
|
||||
: [];
|
||||
|
||||
const values = [
|
||||
...unknownArray(record.artifacts),
|
||||
...unknownArray(record.outputs),
|
||||
...unknownArray(record.files),
|
||||
...unknownArray(record.attachments),
|
||||
];
|
||||
for (const key of ["artifact", "outputPath", "output_path", "filePath", "file_path", "path", "uri", "url"]) {
|
||||
if (record[key]) values.push(record[key]);
|
||||
}
|
||||
|
||||
return values
|
||||
.map((value, index) => artifactFromValue(value, index))
|
||||
.filter((artifact) => artifact.name || artifact.uri);
|
||||
}
|
||||
|
||||
function artifactFromValue(value: unknown, index: number): ExtractedArtifact {
|
||||
const record = objectRecord(value);
|
||||
if (record) {
|
||||
const uri = stringValue(record.uri ?? record.url ?? record.path ?? record.filePath ?? record.file_path ?? record.outputPath ?? record.output_path);
|
||||
const name = stringValue(record.name ?? record.title ?? record.filename) || artifactName(uri) || `产物 ${index + 1}`;
|
||||
const kind = stringValue(record.kind ?? record.type) || "artifact";
|
||||
return {
|
||||
key: stringValue(record.id ?? record.artifact_id) || uri || name,
|
||||
kind,
|
||||
name,
|
||||
uri: uri || null,
|
||||
payload: value,
|
||||
};
|
||||
}
|
||||
|
||||
const text = String(value ?? "").trim();
|
||||
return {
|
||||
key: text,
|
||||
kind: "artifact",
|
||||
name: artifactName(text) || `产物 ${index + 1}`,
|
||||
uri: looksLikeArtifactUri(text) ? text : null,
|
||||
payload: value,
|
||||
};
|
||||
}
|
||||
|
||||
interface ExtractedApproval {
|
||||
key: string;
|
||||
title: string;
|
||||
status: string;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
function extractApprovals(payload: unknown): ExtractedApproval[] {
|
||||
const record = objectRecord(payload);
|
||||
if (!record) return [];
|
||||
|
||||
const values = [
|
||||
...unknownArray(record.approvals),
|
||||
...unknownArray(record.approval_requests),
|
||||
...unknownArray(record.approvalRequests),
|
||||
...unknownArray(record.decisions),
|
||||
];
|
||||
|
||||
if (record.needApproval === true || record.need_approval === true || record.requiresApproval === true) {
|
||||
values.push(record);
|
||||
}
|
||||
|
||||
return values.map((value, index) => approvalFromValue(value, index));
|
||||
}
|
||||
|
||||
function approvalFromValue(value: unknown, index: number): ExtractedApproval {
|
||||
const record = objectRecord(value);
|
||||
if (!record) {
|
||||
return {
|
||||
key: String(index + 1),
|
||||
title: `待确认事项 ${index + 1}`,
|
||||
status: "pending",
|
||||
payload: value,
|
||||
};
|
||||
}
|
||||
|
||||
const title = stringValue(record.title ?? record.message ?? record.reason ?? record.summary)
|
||||
|| `待确认事项 ${index + 1}`;
|
||||
return {
|
||||
key: stringValue(record.id ?? record.approval_id ?? record.key) || title,
|
||||
title,
|
||||
status: stringValue(record.status) || "pending",
|
||||
payload: value,
|
||||
};
|
||||
}
|
||||
|
||||
function objectRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function unknownArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function looksLikeArtifactUri(value: string): boolean {
|
||||
return /^(file|https?):\/\//i.test(value) || value.includes("/") || value.includes("\\");
|
||||
}
|
||||
|
||||
function artifactName(value: string): string {
|
||||
if (!value) return "";
|
||||
const normalized = value.split(/[?#]/)[0] || value;
|
||||
return normalized.split(/[\\/]/).filter(Boolean).pop() ?? "";
|
||||
}
|
||||
|
||||
function dedupeByKey<T>(items: T[], keyOf: (item: T) => string): T[] {
|
||||
const seen = new Set<string>();
|
||||
return items.filter((item) => {
|
||||
const key = keyOf(item);
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function chatIds(request: DigitalEmployeeChatRequestRecord): {
|
||||
planId: string;
|
||||
taskId: string;
|
||||
|
||||
Reference in New Issue
Block a user