沉淀数字员工角色偏好状态
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react';
|
||||
import { readDigitalEmployeeUiState, saveDigitalEmployeeUiStatePatch } from '@/lib/digitalUiStateBridge';
|
||||
|
||||
export type ConsoleRole = 'finance' | 'engineering' | 'executive' | 'hr' | 'sales';
|
||||
export type MotionLevel = 'none' | 'minimal';
|
||||
@@ -136,12 +137,32 @@ function getStoredRole(): ConsoleRole {
|
||||
export function RoleProvider({ children }: { children: ReactNode }) {
|
||||
const [role, setRoleState] = useState<ConsoleRole>(getStoredRole);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void readDigitalEmployeeUiState().then((state) => {
|
||||
if (cancelled) return;
|
||||
const storedRole = state?.consoleRole;
|
||||
if (storedRole && storedRole in ROLE_PROFILES) {
|
||||
setRoleState(storedRole as ConsoleRole);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, storedRole);
|
||||
localStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const setRole = useCallback((newRole: ConsoleRole) => {
|
||||
setRoleState(newRole);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, newRole);
|
||||
localStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||
} catch { /* noop */ }
|
||||
void saveDigitalEmployeeUiStatePatch(
|
||||
{ consoleRole: newRole },
|
||||
{ action: 'update_console_role', metadata: { role: newRole } },
|
||||
);
|
||||
}, []);
|
||||
|
||||
const profile = ROLE_PROFILES[role];
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
export interface DigitalEmployeeProfileState {
|
||||
name: string;
|
||||
company: string;
|
||||
position: string;
|
||||
currentStatus: string;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeUiState {
|
||||
selectedDutyIdsByDate: Record<string, string[]>;
|
||||
confirmedDates: Record<string, string>;
|
||||
reportScheduleTime: string;
|
||||
reportGeneratedAtByDate: Record<string, string>;
|
||||
decisionResultsByDate: Record<string, Record<string, string>>;
|
||||
profile: DigitalEmployeeProfileState;
|
||||
consoleRole?: string;
|
||||
rolePreferences?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface DigitalEmployeeBridgeWindow {
|
||||
QimingClawBridge?: {
|
||||
digital?: {
|
||||
getUiState?: () => Promise<DigitalEmployeeUiState>;
|
||||
saveUiState?: (update: {
|
||||
state: DigitalEmployeeUiState;
|
||||
action?: string;
|
||||
date?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}) => Promise<DigitalEmployeeUiState>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultDigitalEmployeeUiState(): DigitalEmployeeUiState {
|
||||
return {
|
||||
selectedDutyIdsByDate: {},
|
||||
confirmedDates: {},
|
||||
reportScheduleTime: '18:00',
|
||||
reportGeneratedAtByDate: {},
|
||||
decisionResultsByDate: {},
|
||||
profile: {
|
||||
name: '飞天数字员工',
|
||||
company: 'qimingclaw 客户端',
|
||||
position: '客户端任务执行员',
|
||||
currentStatus: 'working',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function readDigitalEmployeeUiState(): Promise<DigitalEmployeeUiState | null> {
|
||||
try {
|
||||
return await (window as DigitalEmployeeBridgeWindow).QimingClawBridge?.digital?.getUiState?.() ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveDigitalEmployeeUiStatePatch(
|
||||
patch: Partial<DigitalEmployeeUiState>,
|
||||
options: { action?: string; date?: string; metadata?: Record<string, unknown> } = {},
|
||||
): Promise<DigitalEmployeeUiState | null> {
|
||||
const bridge = (window as DigitalEmployeeBridgeWindow).QimingClawBridge?.digital;
|
||||
if (!bridge?.saveUiState) return null;
|
||||
try {
|
||||
const current = await bridge.getUiState?.().catch(() => null) ?? null;
|
||||
const state = {
|
||||
...defaultDigitalEmployeeUiState(),
|
||||
...(current ?? {}),
|
||||
...patch,
|
||||
};
|
||||
return await bridge.saveUiState({ state, ...options });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,8 @@ interface AdapterState {
|
||||
reportScheduleTime: string;
|
||||
reportGeneratedAtByDate: Record<string, string>;
|
||||
decisionResultsByDate: Record<string, Record<string, string>>;
|
||||
consoleRole?: string;
|
||||
rolePreferences?: Record<string, unknown>;
|
||||
profile: {
|
||||
name: string;
|
||||
company: string;
|
||||
@@ -484,6 +486,8 @@ function defaultState(): AdapterState {
|
||||
reportScheduleTime: DEFAULT_REPORT_TIME,
|
||||
reportGeneratedAtByDate: {},
|
||||
decisionResultsByDate: {},
|
||||
consoleRole: 'sales',
|
||||
rolePreferences: {},
|
||||
profile: {
|
||||
name: '飞天数字员工',
|
||||
company: 'qimingclaw 客户端',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, type CSSProperties } from 'react';
|
||||
import GlassCard from '@/components/digital/GlassCard';
|
||||
import CardTitleBar from '@/components/digital/CardTitleBar';
|
||||
import { useRole, ROLE_PROFILES, type ConsoleRole } from '@/contexts/RoleContext';
|
||||
import { readDigitalEmployeeUiState, saveDigitalEmployeeUiStatePatch } from '@/lib/digitalUiStateBridge';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
const ROLES: ConsoleRole[] = ['sales', 'engineering', 'hr', 'finance', 'executive'];
|
||||
@@ -67,6 +68,11 @@ function loadPreferences(): RolePreferences {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePreferences(value: unknown): RolePreferences {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return defaultPreferences;
|
||||
return { ...defaultPreferences, ...value as Partial<RolePreferences> };
|
||||
}
|
||||
|
||||
export default function OpsSettings() {
|
||||
const { role, profile, setRole } = useRole();
|
||||
const [preferences, setPreferences] = useState<RolePreferences>(loadPreferences);
|
||||
@@ -77,6 +83,20 @@ export default function OpsSettings() {
|
||||
return () => { if (savedTimerRef.current) clearTimeout(savedTimerRef.current); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void readDigitalEmployeeUiState().then((state) => {
|
||||
if (cancelled || !state?.rolePreferences) return;
|
||||
const next = normalizePreferences(state.rolePreferences);
|
||||
setPreferences(next);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
localStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||
} catch { /* localStorage blocked */ }
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const markSaved = () => {
|
||||
setSaved(true);
|
||||
if (savedTimerRef.current) clearTimeout(savedTimerRef.current);
|
||||
@@ -89,6 +109,10 @@ export default function OpsSettings() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
localStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||
} catch { /* localStorage blocked */ }
|
||||
void saveDigitalEmployeeUiStatePatch(
|
||||
{ rolePreferences: next as unknown as Record<string, unknown> },
|
||||
{ action: 'update_role_preferences', metadata: { preferences: next } },
|
||||
);
|
||||
markSaved();
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,7 +16,7 @@
|
||||
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
|
||||
}
|
||||
</script>
|
||||
<script type="module" crossorigin src="./assets/index-B4ynQSIP.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-C3UI1hbu.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CKIQdvco.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -156,6 +156,50 @@ describe("digital employee state service", () => {
|
||||
expect(mockState.ensureSchemaCalls).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("persists console role and role preferences in UI state", async () => {
|
||||
const { readDigitalEmployeeUiState, saveDigitalEmployeeUiState } = await import("./stateService");
|
||||
|
||||
saveDigitalEmployeeUiState({
|
||||
action: "update_role_preferences",
|
||||
metadata: { source: "ops-settings" },
|
||||
state: {
|
||||
selectedDutyIdsByDate: {},
|
||||
confirmedDates: {},
|
||||
reportScheduleTime: "17:30",
|
||||
reportGeneratedAtByDate: {},
|
||||
decisionResultsByDate: {},
|
||||
consoleRole: "engineering",
|
||||
rolePreferences: {
|
||||
workStart: "10:00",
|
||||
workEnd: "19:00",
|
||||
notification: "system",
|
||||
},
|
||||
profile: {
|
||||
name: "飞天数字员工",
|
||||
company: "qimingclaw 客户端",
|
||||
position: "客户端任务执行员",
|
||||
currentStatus: "working",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(readDigitalEmployeeUiState()).toMatchObject({
|
||||
reportScheduleTime: "17:30",
|
||||
consoleRole: "engineering",
|
||||
rolePreferences: {
|
||||
workStart: "10:00",
|
||||
workEnd: "19:00",
|
||||
notification: "system",
|
||||
},
|
||||
});
|
||||
const preferenceEvent = Array.from(mockState.db?.events.values() ?? [])
|
||||
.find((event) => event.kind === "digital_workday_update_role_preferences");
|
||||
expect(preferenceEvent).toMatchObject({
|
||||
kind: "digital_workday_update_role_preferences",
|
||||
message: "数字员工运营偏好已更新",
|
||||
});
|
||||
});
|
||||
|
||||
it("records governance commands as formal runtime records", async () => {
|
||||
const { recordDigitalEmployeeGovernanceCommand } = await import("./stateService");
|
||||
|
||||
|
||||
@@ -168,6 +168,8 @@ export interface DigitalEmployeeUiState {
|
||||
reportScheduleTime: string;
|
||||
reportGeneratedAtByDate: Record<string, string>;
|
||||
decisionResultsByDate: Record<string, Record<string, string>>;
|
||||
consoleRole?: string;
|
||||
rolePreferences?: Record<string, unknown>;
|
||||
profile: {
|
||||
name: string;
|
||||
company: string;
|
||||
@@ -258,6 +260,8 @@ function defaultUiState(): DigitalEmployeeUiState {
|
||||
reportScheduleTime: "18:00",
|
||||
reportGeneratedAtByDate: {},
|
||||
decisionResultsByDate: {},
|
||||
consoleRole: "sales",
|
||||
rolePreferences: {},
|
||||
profile: {
|
||||
name: "飞天数字员工",
|
||||
company: "qimingclaw 客户端",
|
||||
@@ -304,6 +308,11 @@ function normalizeNestedStringMap(value: unknown): Record<string, Record<string,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeRecord(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||
return { ...value as Record<string, unknown> };
|
||||
}
|
||||
|
||||
function normalizeUiState(
|
||||
value: Partial<DigitalEmployeeUiState>,
|
||||
): DigitalEmployeeUiState {
|
||||
@@ -317,6 +326,10 @@ function normalizeUiState(
|
||||
: fallback.reportScheduleTime,
|
||||
reportGeneratedAtByDate: normalizeStringMap(value.reportGeneratedAtByDate),
|
||||
decisionResultsByDate: normalizeNestedStringMap(value.decisionResultsByDate),
|
||||
consoleRole: typeof value.consoleRole === "string" && value.consoleRole.trim()
|
||||
? value.consoleRole
|
||||
: fallback.consoleRole,
|
||||
rolePreferences: normalizeRecord(value.rolePreferences),
|
||||
profile: {
|
||||
name: typeof profile.name === "string" && profile.name.trim()
|
||||
? profile.name
|
||||
@@ -347,6 +360,10 @@ function digitalEmployeeUiActionMessage(action: string, date?: string): string {
|
||||
return `数字员工日报已生成${suffix}`;
|
||||
case "decide_approval":
|
||||
return `数字员工待决策事项已处理${suffix}`;
|
||||
case "update_console_role":
|
||||
return "数字员工角色已更新";
|
||||
case "update_role_preferences":
|
||||
return "数字员工运营偏好已更新";
|
||||
default:
|
||||
return `数字员工页面状态已更新${suffix}`;
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ POST /api/skill/:skillId/enabled
|
||||
当前数据来源:
|
||||
|
||||
- qimingclaw adapter 内置的客户端任务职责。
|
||||
- `localStorage` 保存的今日任务选择、特性设置、日报生成状态。
|
||||
- 主进程 SQLite settings 保存的今日任务选择、角色偏好、运营偏好和日报生成状态;webview `localStorage` 仅保留为非 Electron 环境兜底和旧 key 迁移来源。
|
||||
- qimingclaw 语义的 ACP / MCP / computer server / artifact-report 技能定义。
|
||||
|
||||
当前页面表现:
|
||||
@@ -446,7 +446,7 @@ crates/agent-electron-client/src/preload/webviewPerfBridge.ts
|
||||
- 数字员工任务设置工具栏已增加“同步管理端”按钮,可通过 bridge 立即触发 `flushSync` 并刷新页面投影。
|
||||
- 数字员工首页已增加管理端同步状态条,展示待同步数量、失败数量、可重试/退避分布、失败实体类型分布、最近同步时间和最近失败原因摘要。
|
||||
- 数字员工嵌入页会通过 `window.QimingClawBridge.digital.getRuntimeRecords()` 读取本地 SQLite 的 `digital_plans`、`digital_tasks`、`digital_runs`、`digital_events`、`digital_artifacts`、`digital_approvals`,优先用真实本地记录生成 workday、任务中心、任务详情和流程投影;本地记录为空时才回落到 qimingclaw 固定适配任务。
|
||||
- 数字员工页面的任务确认、资料设置、日报状态通过 `getUiState` / `saveUiState` 保存在主进程 SQLite settings;保存时会记录 `digital_workday_*` 本地事件并进入 outbox,避免这些页面操作只留在 webview localStorage。
|
||||
- 数字员工页面的任务确认、资料设置、角色选择、运营偏好、日报状态通过 `getUiState` / `saveUiState` 保存在主进程 SQLite settings;保存时会记录 `digital_workday_*` 本地事件并进入 outbox,避免这些页面操作只留在 webview localStorage。
|
||||
- 数字员工技能库通过 `getSkillCapabilities` 读取 qimingclaw 当前 MCP 配置、server 状态、已发现工具与 allow/deny 限制,并把 MCP server/tool 投射为 `/api/skill` 技能项。
|
||||
- 数字员工调试存储、dashboard、flow 和产物列表通过 qimingclaw adapter 的 `/api/debug/store`、`/api/dashboard`、`/api/debug/plan/:id/flow`、`/api/debug/task/:id/flow`、`/api/artifact` 投射本地 Plan / Task / Run / Event / Artifact / Approval;Run/Event payload 中的 `artifacts`、`outputs`、`files`、`outputPath`、`uri` 等字段会作为正式 artifact 记录为空时的兼容产物入口展示。
|
||||
- qimingclaw 主进程记录 `/computer/chat` 结果和 runtime event 时,会从 payload 的 `artifacts`、`outputs`、`files`、`attachments`、`outputPath`、`uri` 以及 `approvals`、`approval_requests`、`needApproval` 等字段轻量提取正式 `digital_artifacts` / `digital_approvals` 记录,并进入 outbox 同步。
|
||||
|
||||
Reference in New Issue
Block a user