feat(client): bridge digital employee status

This commit is contained in:
baiyanyun
2026-06-05 15:59:48 +08:00
parent 31b4b5a281
commit 15a3285ed5
7 changed files with 268 additions and 60 deletions

View File

@@ -8,5 +8,6 @@ This directory records qimingclaw-only adaptations around the sgRobot digital em
- `src/lib/qimingclawAdapter.ts` provides the first qimingclaw-native digital employee API projection.
- `src/lib/api.ts` calls the qimingclaw adapter before falling back to normal HTTP fetch.
- `index.html` injects a local token so copied sgRobot pages do not show the pairing screen.
- `window.QimingClawBridge.digital.getSnapshot()` is read when the page runs inside qimingclaw's Electron webview.
Run `npm run sync:sgrobot-digital` from `crates/agent-electron-client` after changing the upstream sgRobot digital UI.

View File

@@ -17,6 +17,11 @@ import type {
declare global {
interface Window {
__QIMINGCLAW_EMBEDDED_DIGITAL__?: boolean;
QimingClawBridge?: {
digital?: {
getSnapshot?: () => Promise<QimingclawSnapshot>;
};
};
}
}
@@ -36,6 +41,25 @@ interface AdapterState {
};
}
interface QimingclawServiceStatus {
running?: boolean;
error?: string;
engineType?: string;
port?: number;
serverCount?: number;
serverNames?: string[];
}
interface QimingclawSnapshot {
generatedAt: string;
services: Record<string, QimingclawServiceStatus | null>;
sessions: {
total: number;
active: number;
items: Array<{ id: string; title?: string; status?: string; engineType?: string; projectId?: string }>;
};
}
const SKILLS: SkillSpec[] = [
{
skill_id: 'qimingclaw-acp-session',
@@ -180,23 +204,23 @@ export async function handleQimingclawDigitalApi<T>(
const pathname = url.pathname;
if (method === 'GET' && pathname === '/api/digital-employee/workday') {
return buildWorkday(url.searchParams.get('date') || todayInputDate()) as T;
return buildWorkday(url.searchParams.get('date') || todayInputDate(), await readQimingclawSnapshot()) as T;
}
if (method === 'POST' && pathname === '/api/digital-employee/workday/actions') {
return handleWorkdayAction(parseJsonBody<DigitalEmployeeWorkdayActionRequest>(options.body)) as T;
return handleWorkdayAction(parseJsonBody<DigitalEmployeeWorkdayActionRequest>(options.body), await readQimingclawSnapshot()) as T;
}
if (method === 'GET' && pathname === '/api/scheduler/jobs') {
return { jobs: buildSchedulerJobs() } as T;
}
if (method === 'GET' && pathname === '/api/debug/plans') {
return { plans: filterPlans(url.searchParams.get('q')) } as T;
return { plans: filterPlans(url.searchParams.get('q'), await readQimingclawSnapshot()) } as T;
}
if (method === 'GET' && pathname === '/api/debug/tasks') {
const planId = url.searchParams.get('plan_id');
return buildTasksResponse(planId) as T;
return buildTasksResponse(planId, await readQimingclawSnapshot()) as T;
}
if (method === 'GET' && pathname.startsWith('/api/debug/plan/') && pathname.endsWith('/flow')) {
return buildPlanFlow(decodeURIComponent(pathname.split('/')[4] || '')) as T;
return buildPlanFlow(decodeURIComponent(pathname.split('/')[4] || ''), await readQimingclawSnapshot()) as T;
}
if (method === 'POST' && pathname.startsWith('/api/debug/plan/') && pathname.endsWith('/dispatch')) {
return dispatchPlan(decodeURIComponent(pathname.split('/')[4] || '')) as T;
@@ -244,6 +268,14 @@ function writeState(state: AdapterState): void {
window.localStorage.setItem(STATE_KEY, JSON.stringify(state));
}
async function readQimingclawSnapshot(): Promise<QimingclawSnapshot | null> {
try {
return await window.QimingClawBridge?.digital?.getSnapshot?.() ?? null;
} catch {
return null;
}
}
function parseJsonBody<T>(body: BodyInit | null | undefined): T {
if (typeof body !== 'string' || !body.trim()) return {} as T;
return JSON.parse(body) as T;
@@ -279,6 +311,43 @@ function planStatusTone(status: string): string {
return 'waiting';
}
function isRunning(snapshot: QimingclawSnapshot | null, key: string): boolean {
return snapshot?.services[key]?.running === true;
}
function serviceError(snapshot: QimingclawSnapshot | null, key: string): string | undefined {
return snapshot?.services[key]?.error;
}
function runtimeStatusForPlan(plan: DebugPlan, snapshot: QimingclawSnapshot | null): string {
if (!snapshot) return plan.status;
if (plan.plan_id === 'qimingclaw-client-readiness') {
if (isRunning(snapshot, 'agent') && isRunning(snapshot, 'mcp')) return 'running';
if (serviceError(snapshot, 'agent') || serviceError(snapshot, 'mcp')) return 'failed';
return 'pending';
}
if (plan.plan_id === 'qimingclaw-remote-task-ingress') {
if (isRunning(snapshot, 'computerServer') || isRunning(snapshot, 'lanproxy') || isRunning(snapshot, 'fileServer')) return 'running';
if (serviceError(snapshot, 'computerServer') || serviceError(snapshot, 'lanproxy') || serviceError(snapshot, 'fileServer')) return 'failed';
return 'pending';
}
return plan.status;
}
function serviceLine(snapshot: QimingclawSnapshot | null, key: string, label: string): string {
const service = snapshot?.services[key];
if (!service) return `${label}:未连接`;
if (service.running) {
const suffix = service.port ? `(端口 ${service.port}` : '';
return `${label}:运行中${suffix}`;
}
return `${label}:未运行${service.error ? `${service.error}` : ''}`;
}
function withRuntimePlan(plan: DebugPlan, snapshot: QimingclawSnapshot | null): DebugPlan {
return { ...plan, status: runtimeStatusForPlan(plan, snapshot) };
}
function planStatusLabel(status: string): string {
if (status === 'running') return '执行中';
if (status === 'completed' || status === 'succeeded') return '已完成';
@@ -313,9 +382,10 @@ function buildSchedulerJobs(): CronJob[] {
});
}
function buildDuties(state: AdapterState, date: string): DigitalEmployeeDuty[] {
function buildDuties(state: AdapterState, date: string, snapshot: QimingclawSnapshot | null): DigitalEmployeeDuty[] {
const selected = selectedSetForDate(state, date);
return PLANS.map((plan) => {
return PLANS.map((rawPlan) => {
const plan = withRuntimePlan(rawPlan, snapshot);
const schedule = PLAN_SCHEDULES[plan.plan_id] ?? PLAN_SCHEDULES['qimingclaw-client-readiness']!;
return {
id: plan.plan_id,
@@ -328,19 +398,38 @@ function buildDuties(state: AdapterState, date: string): DigitalEmployeeDuty[] {
status_label: planStatusLabel(plan.status),
status_tone: planStatusTone(plan.status),
schedule_text: schedule.scheduleText,
result_text: plan.status === 'running' ? '正在同步客户端运行状态' : '等待任务执行',
result_text: planResultText(plan, snapshot),
conversation_id: plan.plan_id,
};
});
}
function buildTasks(planId?: string | null): DebugTask[] {
function planResultText(plan: DebugPlan, snapshot: QimingclawSnapshot | null): string {
if (plan.plan_id === 'qimingclaw-client-readiness') {
return [
serviceLine(snapshot, 'agent', 'Agent'),
serviceLine(snapshot, 'mcp', 'MCP'),
`活跃会话:${snapshot?.sessions.active ?? 0}`,
].join('');
}
if (plan.plan_id === 'qimingclaw-remote-task-ingress') {
return [
serviceLine(snapshot, 'computerServer', 'Computer Server'),
serviceLine(snapshot, 'lanproxy', 'Lanproxy'),
serviceLine(snapshot, 'fileServer', 'File Server'),
].join('');
}
return plan.status === 'running' ? '正在同步客户端运行状态' : '等待任务执行';
}
function buildTasks(planId?: string | null, snapshot: QimingclawSnapshot | null = null): DebugTask[] {
return PLANS
.filter((plan) => !planId || plan.plan_id === planId)
.map((plan) => withRuntimePlan(plan, snapshot))
.flatMap((plan) => (plan.steps ?? []).flatMap((step) => (step.tasks ?? []).map((task) => ({
task_id: task.task_id,
title: task.title,
status: task.status,
status: plan.status === 'running' && task.status === 'pending' ? 'running' : task.status,
plan_id: plan.plan_id,
step_id: step.step_id,
assigned_skill_id: step.preferred_skills?.[0] ?? '',
@@ -375,22 +464,23 @@ function buildRuns(tasks: DebugTask[]): DebugRun[] {
}));
}
function buildTasksResponse(planId?: string | null): { tasks: DebugTask[]; runs: DebugRun[]; total: number; truncated: boolean } {
const tasks = buildTasks(planId);
function buildTasksResponse(planId?: string | null, snapshot: QimingclawSnapshot | null = null): { tasks: DebugTask[]; runs: DebugRun[]; total: number; truncated: boolean } {
const tasks = buildTasks(planId, snapshot);
return { tasks, runs: buildRuns(tasks), total: tasks.length, truncated: false };
}
function filterPlans(query?: string | null): DebugPlan[] {
function filterPlans(query?: string | null, snapshot: QimingclawSnapshot | null = null): DebugPlan[] {
const normalized = (query ?? '').trim().toLowerCase();
if (!normalized) return PLANS;
return PLANS.filter((plan) => (
const plans = PLANS.map((plan) => withRuntimePlan(plan, snapshot));
if (!normalized) return plans;
return plans.filter((plan) => (
plan.title.toLowerCase().includes(normalized)
|| plan.plan_id.toLowerCase().includes(normalized)
|| (plan.objective ?? '').toLowerCase().includes(normalized)
));
}
function buildEvents(duties: DigitalEmployeeDuty[], confirmed: boolean): DigitalEmployeeWorkEvent[] {
function buildEvents(duties: DigitalEmployeeDuty[], confirmed: boolean, snapshot: QimingclawSnapshot | null): DigitalEmployeeWorkEvent[] {
if (!confirmed) {
return [{
id: 'event-await-confirmation',
@@ -408,18 +498,18 @@ function buildEvents(duties: DigitalEmployeeDuty[], confirmed: boolean): Digital
}
const selectedDuties = duties.filter((duty) => duty.selected);
return selectedDuties.map((duty, index) => ({
return selectedDuties.map((duty) => ({
id: `event-${duty.id}`,
time: index === 0 ? '09:05' : '待执行',
time: duty.status === 'running' ? '实时' : '待执行',
plan_title: duty.title,
task_title: duty.title,
step_title: index === 0 ? '接入 qimingclaw 执行链路' : '等待调度',
detail: index === 0
? '已接入 qimingclaw 客户端服务状态与执行入口,等待后续绑定真实 Run 事件。'
step_title: duty.status === 'running' ? '同步 qimingclaw 服务状态' : '等待调度',
detail: duty.status === 'running'
? `${duty.result_text}。快照时间:${snapshot?.generatedAt ?? '未连接 bridge'}`
: '任务已确认,将在后续阶段接入 ACP/MCP 真实执行事件。',
status: index === 0 ? 'running' : 'pending',
status_label: index === 0 ? '执行中' : '待执行',
status_tone: index === 0 ? 'running' : 'waiting',
status: duty.status === 'running' ? 'running' : 'pending',
status_label: duty.status === 'running' ? '执行中' : '待执行',
status_tone: duty.status === 'running' ? 'running' : 'waiting',
updated_at: new Date().toISOString(),
conversation_id: duty.conversation_id,
}));
@@ -465,12 +555,12 @@ function buildRunDetails(events: DigitalEmployeeWorkEvent[]): DigitalEmployeeRun
}));
}
function buildWorkday(date: string): DigitalEmployeeWorkdayProjection {
function buildWorkday(date: string, snapshot: QimingclawSnapshot | null): DigitalEmployeeWorkdayProjection {
const state = readState();
const confirmedAt = state.confirmedDates[date] ?? null;
const confirmed = Boolean(confirmedAt);
const duties = buildDuties(state, date);
const events = buildEvents(duties, confirmed);
const duties = buildDuties(state, date, snapshot);
const events = buildEvents(duties, confirmed, snapshot);
const executionSummaries = buildExecutionSummaries(duties, events);
const generatedAt = state.reportGeneratedAtByDate[date] ?? null;
const selectedCount = duties.filter((duty) => duty.selected).length;
@@ -521,11 +611,14 @@ function buildWorkday(date: string): DigitalEmployeeWorkdayProjection {
decisions: [],
delivery: {
headline: confirmed ? 'qimingclaw 执行链路已接入' : '等待确认任务设置',
summary: '当前阶段已将数字员工页面切换到 qimingclaw adapter下一阶段接入真实 ACP/MCP Run 事件。',
summary: '当前阶段已将数字员工页面切换到 qimingclaw adapter并开始读取客户端服务状态。',
lines: [
'已吸收 sgRobot 数字员工 UI 和核心展示模型。',
'已建立 qimingclaw workday/plans/tasks/skills API adapter。',
'后续将把客户端服务状态、会话和执行事件写入真实状态模型。',
serviceLine(snapshot, 'agent', 'Agent'),
serviceLine(snapshot, 'mcp', 'MCP'),
serviceLine(snapshot, 'computerServer', 'Computer Server'),
`活跃会话:${snapshot?.sessions.active ?? 0} / ${snapshot?.sessions.total ?? 0}`,
],
artifacts: [
{ name: '数字员工运行摘要', status: generatedAt ? 'ready' : 'pending' },
@@ -541,7 +634,7 @@ function buildWorkday(date: string): DigitalEmployeeWorkdayProjection {
model: 'qimingclaw-adapter',
source: 'qimingclaw',
summary: generatedAt
? '今日已完成数字员工页面到 qimingclaw adapter 的接入,可继续接入真实执行事件。'
? '今日已完成数字员工页面到 qimingclaw adapter 的接入,并读取了 qimingclaw 服务状态。'
: '日报将在生成后汇总任务选择、运行明细和后续接入建议。',
totals: {
task_count: selectedCount,
@@ -558,7 +651,7 @@ function buildWorkday(date: string): DigitalEmployeeWorkdayProjection {
};
}
function handleWorkdayAction(body: DigitalEmployeeWorkdayActionRequest): DigitalEmployeeWorkdayProjection {
function handleWorkdayAction(body: DigitalEmployeeWorkdayActionRequest, snapshot: QimingclawSnapshot | null): DigitalEmployeeWorkdayProjection {
const state = readState();
const date = body.date || todayInputDate();
@@ -584,11 +677,11 @@ function handleWorkdayAction(body: DigitalEmployeeWorkdayActionRequest): Digital
}
writeState(state);
return buildWorkday(date);
return buildWorkday(date, snapshot);
}
function buildPlanFlow(planId: string): FlowResponse {
const plan = PLANS.find((candidate) => candidate.plan_id === planId) ?? PLANS[0]!;
function buildPlanFlow(planId: string, snapshot: QimingclawSnapshot | null = null): FlowResponse {
const plan = withRuntimePlan(PLANS.find((candidate) => candidate.plan_id === planId) ?? PLANS[0]!, snapshot);
const timestamp = new Date().toISOString();
return {
plan_id: plan.plan_id,
@@ -622,8 +715,8 @@ function buildPlanFlow(planId: string): FlowResponse {
{
seq: 3,
lane: 'execution',
label: plan.status === 'running' ? '等待真实 Run 事件' : '等待调度',
sub_label: '后续阶段接入 qimingclaw 本地状态模型',
label: plan.status === 'running' ? '已读取 qimingclaw 服务状态' : '等待调度',
sub_label: planResultText(plan, snapshot),
timestamp,
color: '#f59e0b',
source_type: 'run',

View File

@@ -13,7 +13,7 @@
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
}
</script>
<script type="module" crossorigin src="./assets/index-Brx6-oKs.js"></script>
<script type="module" crossorigin src="./assets/index-DcEd9LJg.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DqV9Vw1H.css">
</head>
<body>

View File

@@ -166,7 +166,8 @@ const WEBVIEW_PERF_BRIDGE_PRELOAD = path.join(
function shouldInjectWebviewPerfBridge(url: string): boolean {
try {
const parsed = new URL(url);
return /^https?:$/.test(parsed.protocol);
if (/^https?:$/.test(parsed.protocol)) return true;
return parsed.protocol === "file:" && parsed.pathname.includes("/sgrobot-digital/");
} catch {
return false;
}

View File

@@ -1,6 +1,7 @@
import { contextBridge, ipcRenderer } from "electron";
type PerfPayload = Record<string, unknown>;
type ServiceStatus = Record<string, unknown> | null;
const CHAT_ROUTE_RE = /^\/home\/chat\/\d+\/\d+$/;
const CHAT_ROOT_SELECTOR = '[data-qimingclaw-perf-scope="chat-root"]';
@@ -91,4 +92,70 @@ const perf = {
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),
},
};
},
},
});

View File

@@ -267,6 +267,52 @@ POST /api/skill/:skillId/enabled
- 新增本地数字员工状态服务,持久化 Plan / Task / Run / Event / Artifact / Approval。
-`/computer/chat`、ACP session、MCP tool status 写入 Run/Event。
## Phase 3webview 只读状态桥(已开始)
目标:让 embedded 数字员工页能读取 qimingclaw 客户端真实状态,同时保持 webview 权限收敛。
当前 qimingclaw 已扩展:
```text
crates/agent-electron-client/src/preload/webviewPerfBridge.ts
crates/agent-electron-client/src/main/main.ts
```
桥接对象:
```text
window.QimingClawBridge.digital.getSnapshot()
```
当前 snapshot 内容:
- Agent service status
- MCP proxy status
- Computer Server status
- Lanproxy status
- File Server status
- GUI Server status
- ACP detailed sessions summary
安全边界:
- 只读。
- 不暴露 start / stop / restart。
- 不暴露 prompt / shell / computer chat。
- webview 页面只能拿到状态快照,再由 adapter 映射到数字员工页面。
当前页面表现:
- “客户端运行状态巡检”会显示 Agent、MCP、活跃会话数。
- “远程任务接入准备”会显示 Computer Server、Lanproxy、File Server 状态。
- 在非 Electron webview 环境下adapter 自动降级为“未连接 bridge”的 fallback 文案。
下一步:
- 新增 qimingclaw digital employee state service。
- 将 bridge snapshot 写入 Run/Event而不是只作为页面即时 projection。
-`/computer/chat` 请求生成真实 Task / Run / Event。
### 产出
- 数字员工页面可以从源码重新构建。