吸收数字员工入口路由观测
This commit is contained in:
@@ -2027,7 +2027,7 @@
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(96px, 0.58fr) auto minmax(0, 1fr);
|
||||
grid-template-rows: auto auto minmax(96px, 0.58fr) auto minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.de-execution-status-strip {
|
||||
@@ -2044,6 +2044,21 @@
|
||||
}
|
||||
.de-execution-status-strip em { color: #5b7590; font-size: 11px; font-style: normal; white-space: nowrap; }
|
||||
.de-execution-status-strip strong { color: #12344f; font-size: 18px; line-height: 1; }
|
||||
.de-route-summary-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(54px, 0.36fr)) minmax(0, 1fr);
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
padding: 7px 8px;
|
||||
border-radius: 10px;
|
||||
background: rgba(247,251,255,0.88);
|
||||
border: 1px solid rgba(79,172,254,0.12);
|
||||
}
|
||||
.de-route-summary-strip span { min-width: 0; display: grid; gap: 2px; }
|
||||
.de-route-summary-strip em { color: #5b7590; font-size: 10px; font-style: normal; white-space: nowrap; }
|
||||
.de-route-summary-strip strong { color: #12344f; font-size: 14px; line-height: 1; }
|
||||
.de-route-summary-strip .is-warning strong { color: #a33b3b; }
|
||||
.de-route-summary-strip small { min-width: 0; color: #41627f; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.de-task-summary-scroll {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
@@ -3121,7 +3136,7 @@
|
||||
}
|
||||
|
||||
.de-workday-execution-body {
|
||||
grid-template-rows: auto auto auto auto;
|
||||
grid-template-rows: auto auto auto auto auto;
|
||||
}
|
||||
|
||||
.de-task-summary-scroll,
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
DigitalEmployeeDuty,
|
||||
DigitalEmployeeManagedService,
|
||||
DigitalEmployeePlanAgentState,
|
||||
DigitalEmployeeRouteSummary,
|
||||
DigitalEmployeeRunDetail,
|
||||
DigitalEmployeeTaskAgentState,
|
||||
DigitalEmployeeTaskExecutionSummary,
|
||||
@@ -671,7 +672,7 @@ const PLAN_SCHEDULES: Record<string, { expression: string; nextRun: string; sche
|
||||
|
||||
const BUSINESS_VIEW_SOURCE = 'qimingclaw-runtime-projection';
|
||||
|
||||
type BusinessViewKind = 'plans' | 'tasks' | 'runs' | 'events' | 'artifacts' | 'approvals';
|
||||
type BusinessViewKind = 'plans' | 'tasks' | 'runs' | 'events' | 'artifacts' | 'approvals' | 'route-decisions';
|
||||
type BusinessViewRow = Record<string, unknown>;
|
||||
|
||||
export function isQimingclawDigitalApiEnabled(): boolean {
|
||||
@@ -755,7 +756,7 @@ export async function handleQimingclawDigitalApi<T>(
|
||||
const approvalId = decodeURIComponent(approvalActionMatch[1] || '');
|
||||
return await handleApprovalAction(approvalId, parseOptionalJsonBody(options.body), await readQimingclawSnapshot()) as T;
|
||||
}
|
||||
const businessViewMatch = pathname.match(/^\/api\/digital-employee\/(plans|tasks|runs|events|artifacts|approvals)$/);
|
||||
const businessViewMatch = pathname.match(/^\/api\/digital-employee\/(plans|tasks|runs|events|artifacts|approvals|route-decisions)$/);
|
||||
if (method === 'GET' && businessViewMatch) {
|
||||
return buildBusinessViewResponse(businessViewMatch[1] as BusinessViewKind, await readQimingclawSnapshot(), url.searchParams) as T;
|
||||
}
|
||||
@@ -2372,14 +2373,109 @@ function buildBusinessViewResponse(kind: BusinessViewKind, snapshot: QimingclawS
|
||||
events: businessEventRows(snapshot),
|
||||
artifacts: businessArtifactRows(snapshot),
|
||||
approvals: businessApprovalRows(snapshot),
|
||||
'route-decisions': routeDecisionRows(snapshot),
|
||||
};
|
||||
const rows = filterBusinessRows(rowsByKind[kind], searchParams);
|
||||
const rows = kind === 'route-decisions'
|
||||
? filterRouteDecisionRows(rowsByKind[kind], searchParams)
|
||||
: filterBusinessRows(rowsByKind[kind], searchParams);
|
||||
return {
|
||||
...paginateBusinessRows(rows, searchParams),
|
||||
source: BUSINESS_VIEW_SOURCE,
|
||||
};
|
||||
}
|
||||
|
||||
function routeDecisionRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
|
||||
return runtimeEvents(snapshot)
|
||||
.filter((event) => event.kind === 'route_decision')
|
||||
.map((event) => {
|
||||
const payload = asRecord(event.payload) ?? {};
|
||||
const decision = routeDecisionFromRuntimePayload(payload, event);
|
||||
const attentionLevel = routeDecisionAttentionLevel(decision);
|
||||
return {
|
||||
...decision,
|
||||
route_decision_id: decision.id,
|
||||
event_id: event.id,
|
||||
status: decision.policy_result || 'accepted',
|
||||
status_label: routeDecisionStatusLabel(decision),
|
||||
attention_level: attentionLevel,
|
||||
entity_type: 'route_decision',
|
||||
entity_id: decision.id,
|
||||
occurred_at: event.occurredAt,
|
||||
created_at: event.createdAt,
|
||||
updated_at: decision.recorded_at || event.occurredAt,
|
||||
payload,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => stringValue(right.recorded_at).localeCompare(stringValue(left.recorded_at)) || stringValue(right.event_id).localeCompare(stringValue(left.event_id)));
|
||||
}
|
||||
|
||||
function filterRouteDecisionRows(rows: BusinessViewRow[], searchParams: URLSearchParams): BusinessViewRow[] {
|
||||
const routeKind = stringValue(searchParams.get('route_kind'));
|
||||
const intentKind = stringValue(searchParams.get('intent_kind'));
|
||||
const policyResult = stringValue(searchParams.get('policy_result'));
|
||||
return filterBusinessRows(rows, searchParams)
|
||||
.filter((row) => !routeKind || stringValue(row.route_kind) === routeKind)
|
||||
.filter((row) => !intentKind || stringValue(row.intent_kind) === intentKind)
|
||||
.filter((row) => !policyResult || stringValue(row.policy_result) === policyResult);
|
||||
}
|
||||
|
||||
function routeDecisionFromRuntimePayload(payload: Record<string, unknown>, event: QimingclawEventRecord): RouteDecisionRecord {
|
||||
const decision = asRecord(payload.routeDecision ?? payload.route_decision ?? payload) ?? {};
|
||||
return {
|
||||
id: stringValue(decision.id) || event.id,
|
||||
route_kind: stringValue(decision.route_kind ?? decision.routeKind) || 'ChatOnly',
|
||||
intent_kind: stringValue(decision.intent_kind ?? decision.intentKind) || 'chat',
|
||||
reason_codes: uniqueStrings(arrayValue(decision.reason_codes ?? decision.reasonCodes)),
|
||||
candidate_skills: uniqueStrings(arrayValue(decision.candidate_skills ?? decision.candidateSkills)),
|
||||
context_refs: asRecord(decision.context_refs ?? decision.contextRefs) ?? {},
|
||||
created_command_id: stringValue(decision.created_command_id ?? decision.createdCommandId) || null,
|
||||
correlation_id: stringValue(decision.correlation_id ?? decision.correlationId) || event.id,
|
||||
creates_plan: Boolean(decision.creates_plan ?? decision.createsPlan),
|
||||
creates_task_run: Boolean(decision.creates_task_run ?? decision.createsTaskRun),
|
||||
approval_mode: stringValue(decision.approval_mode ?? decision.approvalMode) || null,
|
||||
policy_result: stringValue(decision.policy_result ?? decision.policyResult) || 'accepted',
|
||||
recorded_at: stringValue(decision.recorded_at ?? decision.recordedAt) || event.occurredAt,
|
||||
entrypoint: stringValue(decision.entrypoint) || null,
|
||||
actor: stringValue(decision.actor) || null,
|
||||
};
|
||||
}
|
||||
|
||||
function routeDecisionAttentionLevel(decision: RouteDecisionRecord): 'info' | 'action' | 'warn' | 'error' {
|
||||
if (decision.policy_result && decision.policy_result !== 'accepted') return 'error';
|
||||
if (decision.reason_codes.includes('candidate_skills_disabled') || decision.reason_codes.includes('route_action_missing_context')) return 'warn';
|
||||
if (decision.created_command_id) return 'action';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
function routeDecisionStatusLabel(decision: RouteDecisionRecord): string {
|
||||
const attentionLevel = routeDecisionAttentionLevel(decision);
|
||||
if (attentionLevel === 'error') return '策略拒绝';
|
||||
if (attentionLevel === 'warn') return '需要关注';
|
||||
if (decision.created_command_id) return '已映射命令';
|
||||
return '已记录';
|
||||
}
|
||||
|
||||
function buildRouteDecisionSummary(snapshot: QimingclawSnapshot | null): DigitalEmployeeRouteSummary {
|
||||
const rows = routeDecisionRows(snapshot);
|
||||
const latest = rows[0] ?? null;
|
||||
return {
|
||||
total: rows.length,
|
||||
mapped_count: rows.filter((row) => stringValue(row.created_command_id)).length,
|
||||
attention_count: rows.filter((row) => ['warn', 'error'].includes(stringValue(row.attention_level))).length,
|
||||
latest: latest
|
||||
? {
|
||||
id: stringValue(latest.route_decision_id),
|
||||
route_kind: stringValue(latest.route_kind),
|
||||
intent_kind: stringValue(latest.intent_kind),
|
||||
status_label: stringValue(latest.status_label),
|
||||
attention_level: stringValue(latest.attention_level),
|
||||
created_command_id: stringValue(latest.created_command_id) || null,
|
||||
recorded_at: stringValue(latest.recorded_at),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function businessPlanRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
|
||||
const runtimeById = new Map(runtimePlans(snapshot).map((plan) => [plan.id, plan]));
|
||||
const tasks = buildTasks(null, snapshot);
|
||||
@@ -3242,6 +3338,7 @@ function buildNotifications(snapshot: QimingclawSnapshot | null, state: AdapterS
|
||||
...syncFailureNotifications(snapshot),
|
||||
...managedServiceNotifications(snapshot),
|
||||
...planStepDispatchNotifications(snapshot),
|
||||
...routeDecisionNotifications(snapshot),
|
||||
...taskNotifications(snapshot),
|
||||
]);
|
||||
const overlays = state.notificationStateById ?? {};
|
||||
@@ -3376,6 +3473,31 @@ function planStepDispatchNotifications(snapshot: QimingclawSnapshot | null): Use
|
||||
});
|
||||
}
|
||||
|
||||
function routeDecisionNotifications(snapshot: QimingclawSnapshot | null): UserNotification[] {
|
||||
return routeDecisionRows(snapshot)
|
||||
.filter((row) => ['warn', 'error', 'action'].includes(stringValue(row.attention_level)))
|
||||
.map((row) => {
|
||||
const attentionLevel = stringValue(row.attention_level);
|
||||
const routeId = stringValue(row.route_decision_id) || stringValue(row.event_id);
|
||||
const commandId = stringValue(row.created_command_id);
|
||||
const reasonCodes = arrayValue(row.reason_codes).map(stringValue).filter(Boolean).join(' / ');
|
||||
return {
|
||||
notification_id: `route-decision:${routeId}:${slugifyIdPart(stringValue(row.recorded_at))}`,
|
||||
plan_id: stringValue(asRecord(row.context_refs)?.plan_id),
|
||||
task_id: stringValue(asRecord(row.context_refs)?.task_id),
|
||||
kind: attentionLevel === 'action' ? 'task_progress' : 'task_failed',
|
||||
title: attentionLevel === 'action' ? '入口路由已映射' : '入口路由需要关注',
|
||||
body: commandId
|
||||
? `${row.route_kind} / ${row.intent_kind} -> ${commandId}`
|
||||
: `${row.route_kind} / ${row.intent_kind}${reasonCodes ? `:${reasonCodes}` : ''}`,
|
||||
level: attentionLevel === 'error' ? 'error' : attentionLevel === 'warn' ? 'warn' : 'action',
|
||||
status: 'unread',
|
||||
correlation_id: routeId,
|
||||
created_at: stringValue(row.recorded_at) || new Date().toISOString(),
|
||||
} satisfies UserNotification;
|
||||
});
|
||||
}
|
||||
|
||||
function applyNotificationOverlay(notification: UserNotification, overlay?: NotificationState): UserNotification {
|
||||
if (!overlay) return notification;
|
||||
return {
|
||||
@@ -4066,6 +4188,7 @@ function buildWorkday(date: string, snapshot: QimingclawSnapshot | null): Digita
|
||||
})),
|
||||
],
|
||||
},
|
||||
route_summary: buildRouteDecisionSummary(snapshot),
|
||||
daily_report: buildDailyReportProjection({ date, state, generatedAt, selectedCount, events, executionSummaries, runDetails, artifactSources, successCount, failureCount, runningCount, snapshot }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1080,6 +1080,8 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
const managedServiceIssues = managedServices.filter((service) => service.requires_human_action || service.status === 'degraded');
|
||||
const taskAgents = isLiveProjection ? (workdayProjection?.task_agents ?? []) : [];
|
||||
const actionableTaskAgents = taskAgents.filter((agent) => agent.can_retry || agent.can_skip || agent.can_cancel);
|
||||
const routeSummary = isLiveProjection ? workdayProjection?.route_summary : null;
|
||||
const latestRoute = routeSummary?.latest ?? null;
|
||||
const dailyReport = isLiveProjection ? workdayProjection?.daily_report : undefined;
|
||||
const runDetails = isLiveProjection
|
||||
? (workdayProjection?.run_details?.length ? workdayProjection.run_details : allEvents.map(runDetailFromEvent))
|
||||
@@ -2004,6 +2006,15 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
<span><em>异常</em><strong>{failedEvents.length}</strong></span>
|
||||
</div>
|
||||
|
||||
{routeSummary && routeSummary.total > 0 && (
|
||||
<div className="de-route-summary-strip">
|
||||
<span><em>入口路由</em><strong>{routeSummary.total}</strong></span>
|
||||
<span><em>已映射</em><strong>{routeSummary.mapped_count}</strong></span>
|
||||
<span className={routeSummary.attention_count > 0 ? 'is-warning' : ''}><em>需关注</em><strong>{routeSummary.attention_count}</strong></span>
|
||||
<small>{latestRoute ? `${latestRoute.route_kind} / ${latestRoute.intent_kind} · ${latestRoute.status_label}` : '暂无路由决策'}</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="de-task-summary-scroll">
|
||||
<div className="de-task-summary-grid" aria-label="任务执行汇总">
|
||||
{taskSummaryCards.length === 0 ? (
|
||||
|
||||
@@ -835,6 +835,21 @@ export interface DigitalEmployeeDailyReport {
|
||||
pdf_url?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeRouteSummary {
|
||||
total: number;
|
||||
mapped_count: number;
|
||||
attention_count: number;
|
||||
latest?: {
|
||||
id: string;
|
||||
route_kind: string;
|
||||
intent_kind: string;
|
||||
status_label: string;
|
||||
attention_level: string;
|
||||
created_command_id?: string | null;
|
||||
recorded_at: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeWorkdayProjection {
|
||||
source: DigitalEmployeeProjectionSource;
|
||||
demo_reason?: string | null;
|
||||
@@ -851,6 +866,7 @@ export interface DigitalEmployeeWorkdayProjection {
|
||||
run_details?: DigitalEmployeeRunDetail[];
|
||||
decisions: DigitalEmployeeDecision[];
|
||||
delivery: DigitalEmployeeDelivery;
|
||||
route_summary?: DigitalEmployeeRouteSummary;
|
||||
daily_report?: DigitalEmployeeDailyReport;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -16,8 +16,8 @@
|
||||
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
|
||||
}
|
||||
</script>
|
||||
<script type="module" crossorigin src="./assets/index-Cb1LtojZ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DKoke9Cq.css">
|
||||
<script type="module" crossorigin src="./assets/index-YhUdeF7L.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DSHN6Btj.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -592,6 +592,19 @@ describe("digital employee state service", () => {
|
||||
contextRefs: { plan_id: "plan-1" },
|
||||
recordedAt: "2026-06-07T08:02:00.000Z",
|
||||
});
|
||||
const attention = recordDigitalEmployeeRouteDecision({
|
||||
idempotencyKey: "route-key-attention",
|
||||
correlationId: "corr-attention",
|
||||
routeKind: "PlanExecution",
|
||||
intentKind: "execute",
|
||||
reasonCodes: ["candidate_skills_disabled", "route_action_missing_context"],
|
||||
candidateSkills: ["qimingclaw-computer-control"],
|
||||
policyResult: "accepted",
|
||||
entrypoint: "webhook",
|
||||
actor: "management-api",
|
||||
envelope: { text: "执行但缺少 task_id" },
|
||||
recordedAt: "2026-06-07T08:03:00.000Z",
|
||||
});
|
||||
|
||||
expect(first).toMatchObject({
|
||||
id: "route-decision:route-key-1",
|
||||
@@ -615,12 +628,23 @@ describe("digital employee state service", () => {
|
||||
},
|
||||
envelope: { text: "开始执行这个步骤" },
|
||||
});
|
||||
expect(JSON.parse(mockState.db?.events.get(attention.id)?.payload ?? "{}")).toMatchObject({
|
||||
routeDecision: {
|
||||
id: attention.id,
|
||||
reason_codes: ["candidate_skills_disabled", "route_action_missing_context"],
|
||||
candidate_skills: ["qimingclaw-computer-control"],
|
||||
entrypoint: "webhook",
|
||||
actor: "management-api",
|
||||
},
|
||||
envelope: { text: "执行但缺少 task_id" },
|
||||
});
|
||||
expect(mockState.db?.outbox.get(`event:insert:${first.id}`)).toMatchObject({
|
||||
entity_type: "event",
|
||||
operation: "insert",
|
||||
status: "pending",
|
||||
});
|
||||
expect(listDigitalEmployeeRouteDecisions()).toEqual([
|
||||
expect.objectContaining({ id: attention.id, reason_codes: ["candidate_skills_disabled", "route_action_missing_context"] }),
|
||||
expect.objectContaining({ id: second.id, route_kind: "ProjectionQuery" }),
|
||||
expect.objectContaining({ id: first.id, route_kind: "PlanExecution", created_command_id: "qimingclaw-digital-start_run-1" }),
|
||||
]);
|
||||
|
||||
@@ -1630,8 +1630,8 @@ function routeDecisionFromEventPayload(payload: unknown): DigitalEmployeeRouteDe
|
||||
id,
|
||||
route_kind: stringValue(decision.route_kind ?? decision.routeKind) || "ChatOnly",
|
||||
intent_kind: stringValue(decision.intent_kind ?? decision.intentKind) || "chat",
|
||||
reason_codes: parseStringArray(decision.reason_codes ?? decision.reasonCodes),
|
||||
candidate_skills: parseStringArray(decision.candidate_skills ?? decision.candidateSkills),
|
||||
reason_codes: parseRouteDecisionStringArray(decision.reason_codes ?? decision.reasonCodes),
|
||||
candidate_skills: parseRouteDecisionStringArray(decision.candidate_skills ?? decision.candidateSkills),
|
||||
context_refs: objectRecord(decision.context_refs ?? decision.contextRefs) ?? {},
|
||||
created_command_id: stringValue(decision.created_command_id ?? decision.createdCommandId) || null,
|
||||
correlation_id: stringValue(decision.correlation_id ?? decision.correlationId) || id,
|
||||
@@ -1645,6 +1645,11 @@ function routeDecisionFromEventPayload(payload: unknown): DigitalEmployeeRouteDe
|
||||
};
|
||||
}
|
||||
|
||||
function parseRouteDecisionStringArray(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return normalizeSkillIds(value);
|
||||
return parseStringArray(value);
|
||||
}
|
||||
|
||||
function routeDecisionMessage(decision: DigitalEmployeeRouteDecisionRecord): string {
|
||||
return `入口路由决策:${decision.route_kind} / ${decision.intent_kind}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user