feat: refine business chat workflow
This commit is contained in:
@@ -7,6 +7,7 @@ import { getOpenClawConfigDir } from '../../utils/paths';
|
||||
import { resolveAccountIdFromSessionHistory } from '../../utils/session-util';
|
||||
import { toOpenClawChannelType, toUiChannelType } from '../../utils/channel-alias';
|
||||
import { resolveAgentIdFromChannel } from '../../utils/agent-config';
|
||||
import { stripBusinessResponseGuidance } from '../../../shared/business-guidance';
|
||||
|
||||
/**
|
||||
* Find agentId from session history by delivery "to" address.
|
||||
@@ -296,7 +297,7 @@ export function buildCronSessionFallbackMessages(params: {
|
||||
});
|
||||
|
||||
const messages: CronSessionFallbackMessage[] = [];
|
||||
const prompt = params.job?.payload?.message || params.job?.payload?.text || '';
|
||||
const prompt = stripBusinessResponseGuidance(params.job?.payload?.message || params.job?.payload?.text || '');
|
||||
const taskName = params.job?.name?.trim()
|
||||
|| params.sessionEntry?.label?.replace(/^Cron:\s*/, '').trim()
|
||||
|| '';
|
||||
@@ -421,7 +422,7 @@ function buildCronUpdatePatch(input: Record<string, unknown>): Record<string, un
|
||||
}
|
||||
|
||||
if (typeof patch.message === 'string') {
|
||||
patch.payload = { kind: 'agentTurn', message: patch.message };
|
||||
patch.payload = { kind: 'agentTurn', message: stripBusinessResponseGuidance(patch.message) };
|
||||
delete patch.message;
|
||||
}
|
||||
|
||||
@@ -487,7 +488,7 @@ async function resolveRecoveredCronRunAfterError(ctx: HostApiContext, jobId: str
|
||||
}
|
||||
|
||||
function transformCronJob(job: GatewayCronJob, resolvedLastRun?: CronRunLogEntry) {
|
||||
const message = job.payload?.message || job.payload?.text || '';
|
||||
const message = stripBusinessResponseGuidance(job.payload?.message || job.payload?.text || '');
|
||||
const gatewayDelivery = normalizeCronDelivery(job.delivery);
|
||||
const channelType = gatewayDelivery.channel ? toUiChannelType(gatewayDelivery.channel) : undefined;
|
||||
const delivery = channelType
|
||||
@@ -752,7 +753,7 @@ export async function handleCronRoutes(
|
||||
const result = await ctx.gatewayManager.rpc('cron.add', {
|
||||
name: input.name,
|
||||
schedule: { kind: 'cron', expr: input.schedule },
|
||||
payload: { kind: 'agentTurn', message: input.message },
|
||||
payload: { kind: 'agentTurn', message: stripBusinessResponseGuidance(input.message) },
|
||||
enabled: input.enabled ?? true,
|
||||
wakeMode: 'next-heartbeat',
|
||||
sessionTarget: 'isolated',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Electron Main Process Entry
|
||||
* Manages window creation, system tray, and IPC handlers
|
||||
*/
|
||||
import { app, BrowserWindow, nativeImage, session, shell } from 'electron';
|
||||
import { app, BrowserWindow, nativeImage, Notification, session, shell } from 'electron';
|
||||
import type { Server } from 'node:http';
|
||||
import { join } from 'path';
|
||||
import { GatewayManager } from '../gateway/manager';
|
||||
@@ -48,6 +48,7 @@ import { deviceOAuthManager } from '../utils/device-oauth';
|
||||
import { browserOAuthManager } from '../utils/browser-oauth';
|
||||
import { whatsAppLoginManager } from '../utils/whatsapp-login';
|
||||
import { syncAllProviderAuthToRuntime } from '../services/providers/provider-runtime-sync';
|
||||
import { createCronDesktopReminderHandler, type CronDesktopReminderJob } from '../utils/cron-desktop-reminder';
|
||||
|
||||
const WINDOWS_APP_USER_MODEL_ID = 'app.zhinian.assistant';
|
||||
const PRODUCT_NAME = '智念助手';
|
||||
@@ -257,13 +258,16 @@ function focusWindow(win: BrowserWindow): void {
|
||||
win.focus();
|
||||
}
|
||||
|
||||
function focusMainWindow(): void {
|
||||
function focusMainWindow(route?: string): void {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearPendingSecondInstanceFocus(mainWindowFocusState);
|
||||
focusWindow(mainWindow);
|
||||
if (route) {
|
||||
mainWindow.webContents.send('navigate', route);
|
||||
}
|
||||
}
|
||||
|
||||
function createMainWindow(): BrowserWindow {
|
||||
@@ -426,6 +430,28 @@ async function initialize(): Promise<void> {
|
||||
|
||||
// Bridge gateway and host-side events before any auto-start logic runs, so
|
||||
// renderer subscribers observe the full startup lifecycle.
|
||||
const handleCronDesktopReminder = createCronDesktopReminderHandler({
|
||||
resolveJob: async (jobId): Promise<CronDesktopReminderJob | undefined> => {
|
||||
const result = await gatewayManager.rpc('cron.list', { includeDisabled: true }, 3000);
|
||||
const jobs = (result as { jobs?: CronDesktopReminderJob[] })?.jobs
|
||||
?? (Array.isArray(result) ? result as CronDesktopReminderJob[] : []);
|
||||
return jobs.find((job) => job.id === jobId);
|
||||
},
|
||||
notify: (display) => {
|
||||
if (!Notification.isSupported()) return;
|
||||
const desktopNotification = new Notification({
|
||||
title: display.title,
|
||||
body: display.body,
|
||||
icon: getAppIconPath(),
|
||||
silent: false,
|
||||
});
|
||||
desktopNotification.on('click', () => {
|
||||
focusMainWindow(display.route);
|
||||
});
|
||||
desktopNotification.show();
|
||||
},
|
||||
});
|
||||
|
||||
gatewayManager.on('status', (status: { state: string }) => {
|
||||
hostEventBus.emit('gateway:status', status);
|
||||
if (status.state === 'running' && !isE2EMode) {
|
||||
@@ -441,6 +467,11 @@ async function initialize(): Promise<void> {
|
||||
|
||||
gatewayManager.on('notification', (notification) => {
|
||||
hostEventBus.emit('gateway:notification', notification);
|
||||
if (!isE2EMode) {
|
||||
void handleCronDesktopReminder(notification).catch((error) => {
|
||||
logger.warn('Failed to show cron desktop reminder:', error);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
gatewayManager.on('chat:message', (data) => {
|
||||
|
||||
243
electron/utils/cron-desktop-reminder.ts
Normal file
243
electron/utils/cron-desktop-reminder.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export type CronDesktopReminderTone = 'success' | 'warning' | 'danger';
|
||||
|
||||
export interface CronDesktopReminderEvent {
|
||||
jobId: string;
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
runKey: string;
|
||||
phase: string;
|
||||
tone: CronDesktopReminderTone;
|
||||
summary?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CronDesktopReminderJob {
|
||||
id: string;
|
||||
name?: string;
|
||||
state?: {
|
||||
lastStatus?: string;
|
||||
lastError?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CronDesktopReminderDisplay {
|
||||
title: string;
|
||||
body: string;
|
||||
tone: CronDesktopReminderTone;
|
||||
route: string;
|
||||
}
|
||||
|
||||
export interface CronDesktopReminderHandlerDeps {
|
||||
resolveJob?: (jobId: string) => Promise<CronDesktopReminderJob | undefined>;
|
||||
notify: (display: CronDesktopReminderDisplay, event: CronDesktopReminderEvent) => void;
|
||||
now?: () => number;
|
||||
dedupeMs?: number;
|
||||
}
|
||||
|
||||
interface ParsedCronSessionKey {
|
||||
agentId: string;
|
||||
jobId: string;
|
||||
runSessionId?: string;
|
||||
fixedSessionKey: string;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): JsonRecord | undefined {
|
||||
return value && typeof value === 'object' ? value as JsonRecord : undefined;
|
||||
}
|
||||
|
||||
function readString(record: JsonRecord | undefined, key: string): string | undefined {
|
||||
const value = record?.[key];
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function parseCronSessionKey(value: unknown): ParsedCronSessionKey | null {
|
||||
if (typeof value !== 'string' || !value.startsWith('agent:')) return null;
|
||||
const parts = value.split(':');
|
||||
if (parts.length < 4 || parts[2] !== 'cron') return null;
|
||||
const agentId = parts[1] || 'main';
|
||||
const jobId = parts[3];
|
||||
if (!jobId) return null;
|
||||
const runSessionId = parts.length >= 6 && parts[4] === 'run' && parts[5] ? parts[5] : undefined;
|
||||
return {
|
||||
agentId,
|
||||
jobId,
|
||||
...(runSessionId ? { runSessionId } : {}),
|
||||
fixedSessionKey: `agent:${agentId}:cron:${jobId}`,
|
||||
};
|
||||
}
|
||||
|
||||
function getNestedRecord(record: JsonRecord | undefined, key: string): JsonRecord | undefined {
|
||||
return asRecord(record?.[key]);
|
||||
}
|
||||
|
||||
function isTerminalCronPhase(phase: string | undefined): boolean {
|
||||
if (!phase) return false;
|
||||
return ['completed', 'done', 'finished', 'failed', 'error'].includes(phase.toLowerCase());
|
||||
}
|
||||
|
||||
function normalizeStatus(value: string | undefined): CronDesktopReminderTone | undefined {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (!normalized) return undefined;
|
||||
if (['error', 'failed', 'failure', 'cancelled', 'timeout'].includes(normalized)) return 'danger';
|
||||
if (['warning', 'warn', 'reconciled', 'partial'].includes(normalized)) return 'warning';
|
||||
if (['ok', 'success', 'succeeded', 'completed', 'done', 'finished'].includes(normalized)) return 'success';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function firstText(values: Array<unknown>): string | undefined {
|
||||
for (const value of values) {
|
||||
const text = extractText(value);
|
||||
if (text) return text;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractText(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') return value.trim() || undefined;
|
||||
if (Array.isArray(value)) {
|
||||
const text = value.map(extractText).filter(Boolean).join('\n').trim();
|
||||
return text || undefined;
|
||||
}
|
||||
const record = asRecord(value);
|
||||
if (!record) return undefined;
|
||||
return firstText([
|
||||
record.content,
|
||||
record.text,
|
||||
record.message,
|
||||
record.summary,
|
||||
record.error,
|
||||
]);
|
||||
}
|
||||
|
||||
function inferTone(phase: string | undefined, params: JsonRecord | undefined, data: JsonRecord | undefined): CronDesktopReminderTone {
|
||||
const state = getNestedRecord(params, 'state') ?? getNestedRecord(data, 'state');
|
||||
const tone = normalizeStatus(readString(data, 'status'))
|
||||
?? normalizeStatus(readString(params, 'status'))
|
||||
?? normalizeStatus(readString(data, 'finalStatus'))
|
||||
?? normalizeStatus(readString(params, 'finalStatus'))
|
||||
?? normalizeStatus(readString(state, 'status'))
|
||||
?? normalizeStatus(readString(state, 'lastStatus'))
|
||||
?? normalizeStatus(phase);
|
||||
if (tone) return tone;
|
||||
if (readString(data, 'error') || readString(params, 'error') || readString(state, 'lastError')) return 'danger';
|
||||
return 'success';
|
||||
}
|
||||
|
||||
function truncateText(value: string | undefined, maxLength: number): string {
|
||||
const clean = (value || '').replace(/\s+/g, ' ').trim();
|
||||
if (!clean) return '';
|
||||
if (clean.length <= maxLength) return clean;
|
||||
return `${clean.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
|
||||
}
|
||||
|
||||
export function buildCronDesktopReminderEvent(notification: unknown): CronDesktopReminderEvent | null {
|
||||
const root = asRecord(notification);
|
||||
if (readString(root, 'method') !== 'agent') return null;
|
||||
|
||||
const params = getNestedRecord(root, 'params');
|
||||
const data = getNestedRecord(params, 'data');
|
||||
const phase = (readString(data, 'phase') ?? readString(params, 'phase'))?.toLowerCase();
|
||||
if (!isTerminalCronPhase(phase)) return null;
|
||||
|
||||
const parsedSession = parseCronSessionKey(readString(params, 'sessionKey') ?? readString(data, 'sessionKey'));
|
||||
if (!parsedSession) return null;
|
||||
|
||||
const state = getNestedRecord(params, 'state') ?? getNestedRecord(data, 'state');
|
||||
const error = firstText([
|
||||
readString(data, 'error'),
|
||||
readString(params, 'error'),
|
||||
readString(state, 'lastError'),
|
||||
]);
|
||||
const summary = firstText([
|
||||
readString(data, 'resolvedSummary'),
|
||||
readString(params, 'resolvedSummary'),
|
||||
readString(data, 'summary'),
|
||||
readString(params, 'summary'),
|
||||
data?.message,
|
||||
params?.message,
|
||||
]);
|
||||
const runId = readString(params, 'runId')
|
||||
?? readString(data, 'runId')
|
||||
?? readString(params, 'sessionId')
|
||||
?? readString(data, 'sessionId')
|
||||
?? parsedSession.runSessionId
|
||||
?? parsedSession.fixedSessionKey;
|
||||
|
||||
return {
|
||||
jobId: parsedSession.jobId,
|
||||
agentId: parsedSession.agentId,
|
||||
sessionKey: parsedSession.fixedSessionKey,
|
||||
runKey: `${parsedSession.jobId}:${runId}`,
|
||||
phase: phase || 'completed',
|
||||
tone: inferTone(phase, params, data),
|
||||
...(summary ? { summary: truncateText(summary, 180) } : {}),
|
||||
...(error ? { error: truncateText(error, 180) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCronDesktopReminderDisplay(
|
||||
event: CronDesktopReminderEvent,
|
||||
job?: CronDesktopReminderJob,
|
||||
): CronDesktopReminderDisplay {
|
||||
const jobName = job?.name?.trim() || event.jobId;
|
||||
const jobTone = normalizeStatus(job?.state?.lastStatus);
|
||||
const tone = jobTone === 'danger' ? 'danger' : event.tone;
|
||||
const error = event.error || job?.state?.lastError;
|
||||
const detail = tone === 'danger' ? error || event.summary : event.summary || error;
|
||||
|
||||
if (tone === 'danger') {
|
||||
return {
|
||||
tone,
|
||||
title: `任务需要处理:${jobName}`,
|
||||
body: truncateText(detail || '任务运行失败,点击查看执行记录。', 220),
|
||||
route: `/tasks?task=${encodeURIComponent(event.jobId)}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (tone === 'warning') {
|
||||
return {
|
||||
tone,
|
||||
title: `任务需复核:${jobName}`,
|
||||
body: truncateText(detail || '任务已完成,但存在需要复核的提示。', 220),
|
||||
route: `/tasks?task=${encodeURIComponent(event.jobId)}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tone,
|
||||
title: `任务已完成:${jobName}`,
|
||||
body: truncateText(detail || '结果已生成,点击查看任务会话。', 220),
|
||||
route: `/tasks?task=${encodeURIComponent(event.jobId)}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCronDesktopReminderHandler(deps: CronDesktopReminderHandlerDeps) {
|
||||
const notifiedRuns = new Map<string, number>();
|
||||
const dedupeMs = deps.dedupeMs ?? 10 * 60 * 1000;
|
||||
const now = deps.now ?? Date.now;
|
||||
|
||||
return async (notification: unknown): Promise<CronDesktopReminderDisplay | null> => {
|
||||
const event = buildCronDesktopReminderEvent(notification);
|
||||
if (!event) return null;
|
||||
|
||||
const currentTime = now();
|
||||
for (const [key, timestamp] of notifiedRuns) {
|
||||
if (currentTime - timestamp > dedupeMs) {
|
||||
notifiedRuns.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
const notifiedAt = notifiedRuns.get(event.runKey);
|
||||
if (notifiedAt != null && currentTime - notifiedAt <= dedupeMs) {
|
||||
return null;
|
||||
}
|
||||
notifiedRuns.set(event.runKey, currentTime);
|
||||
|
||||
const job = await deps.resolveJob?.(event.jobId).catch(() => undefined);
|
||||
const display = buildCronDesktopReminderDisplay(event, job);
|
||||
deps.notify(display, event);
|
||||
return display;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user