feat: update desktop workflows and app center

This commit is contained in:
inman
2026-05-13 19:14:56 +08:00
parent 20b5aff4ad
commit 7c8781a6e3
160 changed files with 55492 additions and 1423 deletions

View File

@@ -37,8 +37,11 @@ interface CronRunLogEntry {
jobId?: string;
action?: string;
status?: string;
resolvedStatus?: string;
error?: string;
summary?: string;
resolvedSummary?: string;
recoveredError?: string;
sessionId?: string;
sessionKey?: string;
ts?: number;
@@ -56,12 +59,17 @@ interface CronSessionKeyParts {
interface CronSessionFallbackMessage {
id: string;
role: 'assistant' | 'system';
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: number;
isError?: boolean;
}
interface CronTrajectoryArtifacts {
finalStatus?: string;
assistantText?: string;
}
function parseCronSessionKey(sessionKey: string): CronSessionKeyParts | null {
if (!sessionKey.startsWith('agent:')) return null;
const parts = sessionKey.split(':');
@@ -102,12 +110,22 @@ function formatDuration(durationMs: number | undefined): string | null {
return `${Math.round(durationMs / 1000)}s`;
}
function getCronRunStatus(entry: CronRunLogEntry): string {
const status = entry.resolvedStatus || entry.status || '';
return typeof status === 'string' ? status.toLowerCase() : '';
}
function getCronRunSummary(entry: CronRunLogEntry): string {
const summary = entry.resolvedSummary || entry.summary || '';
return typeof summary === 'string' ? summary.trim() : '';
}
function buildCronRunMessage(entry: CronRunLogEntry, index: number): CronSessionFallbackMessage | null {
const timestamp = normalizeTimestampMs(entry.ts) ?? normalizeTimestampMs(entry.runAtMs);
if (!timestamp) return null;
const status = typeof entry.status === 'string' ? entry.status.toLowerCase() : '';
const summary = typeof entry.summary === 'string' ? entry.summary.trim() : '';
const status = getCronRunStatus(entry);
const summary = getCronRunSummary(entry);
const error = typeof entry.error === 'string' ? entry.error.trim() : '';
let content = summary || error;
@@ -135,7 +153,7 @@ function buildCronRunMessage(entry: CronRunLogEntry, index: number): CronSession
return {
id: `cron-run-${entry.sessionId ?? entry.ts ?? index}`,
role: status === 'error' ? 'system' : 'assistant',
role: 'assistant',
content,
timestamp,
...(status === 'error' ? { isError: true } : {}),
@@ -163,6 +181,65 @@ async function readCronRunLog(jobId: string): Promise<CronRunLogEntry[]> {
return entries;
}
function extractTrajectoryArtifacts(raw: string): CronTrajectoryArtifacts | undefined {
let latest: CronTrajectoryArtifacts | undefined;
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const entry = JSON.parse(trimmed) as Record<string, unknown>;
if (entry.type !== 'trace.artifacts' || !entry.data || typeof entry.data !== 'object') {
continue;
}
const data = entry.data as Record<string, unknown>;
const finalStatus = typeof data.finalStatus === 'string'
? data.finalStatus
: undefined;
const assistantTexts = Array.isArray(data.assistantTexts)
? data.assistantTexts.filter((text): text is string => typeof text === 'string' && text.trim().length > 0)
: [];
latest = {
...(finalStatus ? { finalStatus } : {}),
...(assistantTexts.length > 0 ? { assistantText: assistantTexts[assistantTexts.length - 1].trim() } : {}),
};
} catch {
// Ignore malformed trajectory lines; cron logs are still useful without them.
}
}
return latest;
}
async function readCronTrajectoryArtifacts(agentId: string, sessionId: string | undefined): Promise<CronTrajectoryArtifacts | undefined> {
if (!sessionId) return undefined;
const trajectoryPath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', `${sessionId}.trajectory.jsonl`);
const raw = await readFile(trajectoryPath, 'utf8').catch(() => '');
if (!raw.trim()) return undefined;
return extractTrajectoryArtifacts(raw);
}
async function resolveCronRunEntry(agentId: string, entry: CronRunLogEntry): Promise<CronRunLogEntry> {
if (getCronRunStatus(entry) !== 'error') return entry;
const artifacts = await readCronTrajectoryArtifacts(agentId, entry.sessionId);
if (artifacts?.finalStatus?.toLowerCase() !== 'success') return entry;
const recoveredError = entry.error || entry.summary;
return {
...entry,
resolvedStatus: 'ok',
...(artifacts.assistantText ? { resolvedSummary: artifacts.assistantText } : {}),
...(recoveredError ? { recoveredError } : {}),
};
}
async function resolveCronRunEntries(agentId: string, entries: CronRunLogEntry[]): Promise<CronRunLogEntry[]> {
return Promise.all(entries.map((entry) => resolveCronRunEntry(agentId, entry)));
}
async function readSessionStoreEntry(
agentId: string,
sessionKey: string,
@@ -227,13 +304,12 @@ export function buildCronSessionFallbackMessages(params: {
? (normalizeTimestampMs(matchingRuns[0]?.runAtMs) ?? normalizeTimestampMs(matchingRuns[0]?.ts))
: (normalizeTimestampMs(params.job?.state?.runningAtMs) ?? params.sessionEntry?.updatedAt);
if (taskName || prompt) {
const lines = [taskName ? `Scheduled task: ${taskName}` : 'Scheduled task'];
if (prompt) lines.push(`Prompt: ${prompt}`);
if (prompt || taskName) {
const content = prompt || `Scheduled task: ${taskName}`;
messages.push({
id: `cron-meta-${parsed.jobId}`,
role: 'system',
content: lines.join('\n'),
id: `cron-query-${parsed.jobId}-${firstRelevantTimestamp ?? 'pending'}`,
role: 'user',
content,
timestamp: Math.max(0, (firstRelevantTimestamp ?? Date.now()) - 1),
});
}
@@ -248,15 +324,15 @@ export function buildCronSessionFallbackMessages(params: {
if (runningAt) {
messages.push({
id: `cron-running-${parsed.jobId}`,
role: 'system',
content: 'This scheduled task is still running in OpenClaw, but no chat transcript is available yet.',
role: 'assistant',
content: '任务正在执行,完成后会自动在这里显示结果。',
timestamp: runningAt,
});
} else if (messages.length === 0) {
messages.push({
id: `cron-empty-${parsed.jobId}`,
role: 'system',
content: 'No chat transcript is available for this scheduled task yet.',
role: 'assistant',
content: '这个任务还没有可显示的执行记录。',
timestamp: params.sessionEntry?.updatedAt ?? Date.now(),
});
}
@@ -364,7 +440,53 @@ function buildCronUpdatePatch(input: Record<string, unknown>): Record<string, un
return patch;
}
function transformCronJob(job: GatewayCronJob) {
function findLatestCronRunEntry(runs: CronRunLogEntry[], lastRunAtMs: number | undefined): CronRunLogEntry | undefined {
const withTime = runs
.map((entry) => ({
entry,
timestamp: normalizeTimestampMs(entry.runAtMs) ?? normalizeTimestampMs(entry.ts) ?? 0,
}))
.filter((item) => item.timestamp > 0);
if (withTime.length === 0) return runs[runs.length - 1];
if (lastRunAtMs) {
const exact = withTime.find((item) => item.timestamp === lastRunAtMs);
if (exact) return exact.entry;
}
return withTime.sort((a, b) => b.timestamp - a.timestamp)[0]?.entry;
}
async function resolveLatestCronRun(job: GatewayCronJob, agentId: string): Promise<CronRunLogEntry | undefined> {
if (!job.state?.lastRunAtMs || job.state.lastStatus === 'ok') return undefined;
const runs = await readCronRunLog(job.id);
const latest = findLatestCronRunEntry(runs, job.state.lastRunAtMs);
return latest ? resolveCronRunEntry(agentId, latest) : undefined;
}
async function resolveRecoveredCronRunAfterError(ctx: HostApiContext, jobId: string): Promise<Record<string, unknown> | undefined> {
const jobsResult = await ctx.gatewayManager.rpc('cron.list', { includeDisabled: true }, 8000)
.catch(() => ({ jobs: [] as GatewayCronJob[] }));
const jobs = (jobsResult as { jobs?: GatewayCronJob[] }).jobs ?? [];
const job = jobs.find((item) => item.id === jobId);
const agentId = (job as unknown as { agentId?: string } | undefined)?.agentId || 'main';
const runs = await readCronRunLog(jobId);
const latest = findLatestCronRunEntry(runs, job?.state?.lastRunAtMs);
if (!latest) return undefined;
const resolvedRun = await resolveCronRunEntry(agentId, latest);
if (getCronRunStatus(resolvedRun) !== 'ok') return undefined;
return {
success: true,
recovered: true,
...(getCronRunSummary(resolvedRun) ? { summary: getCronRunSummary(resolvedRun) } : {}),
...(resolvedRun.recoveredError ? { warning: resolvedRun.recoveredError } : {}),
...(job ? { job: transformCronJob(job, resolvedRun) } : {}),
};
}
function transformCronJob(job: GatewayCronJob, resolvedLastRun?: CronRunLogEntry) {
const message = job.payload?.message || job.payload?.text || '';
const gatewayDelivery = normalizeCronDelivery(job.delivery);
const channelType = gatewayDelivery.channel ? toUiChannelType(gatewayDelivery.channel) : undefined;
@@ -379,12 +501,15 @@ function transformCronJob(job: GatewayCronJob) {
recipient: delivery.to,
}
: undefined;
const resolvedRunStatus = resolvedLastRun ? getCronRunStatus(resolvedLastRun) : '';
const effectiveLastStatus = resolvedRunStatus || job.state?.lastStatus;
const lastRun = job.state?.lastRunAtMs
? {
time: new Date(job.state.lastRunAtMs).toISOString(),
success: job.state.lastStatus === 'ok',
error: job.state.lastError,
success: effectiveLastStatus === 'ok',
...(effectiveLastStatus === 'ok' ? {} : { error: job.state.lastError }),
duration: job.state.lastDurationMs,
...(resolvedLastRun?.recoveredError ? { warning: resolvedLastRun.recoveredError, reconciled: true } : {}),
}
: undefined;
const nextRun = job.state?.nextRunAtMs
@@ -430,12 +555,13 @@ export async function handleCronRoutes(
: 200;
try {
const [jobsResult, runs, sessionEntry] = await Promise.all([
const [jobsResult, rawRuns, sessionEntry] = await Promise.all([
ctx.gatewayManager.rpc('cron.list', { includeDisabled: true }, 8000)
.catch(() => ({ jobs: [] as GatewayCronJob[] })),
readCronRunLog(parsedSession.jobId),
readSessionStoreEntry(parsedSession.agentId, sessionKey),
]);
const runs = await resolveCronRunEntries(parsedSession.agentId, rawRuns);
const jobs = (jobsResult as { jobs?: GatewayCronJob[] }).jobs ?? [];
const job = jobs.find((item) => item.id === parsedSession.jobId);
@@ -589,7 +715,13 @@ export async function handleCronRoutes(
}
}
sendJson(res, 200, jobs.map((job) => ({ ...transformCronJob(job), ...(usedFallback ? { _fromFallback: true } : {}) })));
const responseJobs = await Promise.all(jobs.map(async (job) => {
const agentId = (job as unknown as { agentId?: string }).agentId || 'main';
const resolvedLastRun = await resolveLatestCronRun(job, agentId);
return { ...transformCronJob(job, resolvedLastRun), ...(usedFallback ? { _fromFallback: true } : {}) };
}));
sendJson(res, 200, responseJobs);
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
@@ -684,7 +816,16 @@ export async function handleCronRoutes(
if (url.pathname === '/api/cron/trigger' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ id: string }>(req);
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.run', { id: body.id, mode: 'force' }));
try {
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.run', { id: body.id, mode: 'force' }));
} catch (runError) {
const recovered = await resolveRecoveredCronRunAfterError(ctx, body.id);
if (recovered) {
sendJson(res, 200, { ...recovered, triggerWarning: String(runError) });
return true;
}
throw runError;
}
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}

View File

@@ -0,0 +1,28 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { getAllLocalPreferences, patchLocalPreferences, type AppLocalPreferences } from '../../utils/local-preferences';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
export async function handleLocalPreferenceRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
_ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/local-preferences' && req.method === 'GET') {
sendJson(res, 200, await getAllLocalPreferences());
return true;
}
if (url.pathname === '/api/local-preferences' && req.method === 'PUT') {
try {
const patch = await parseJsonBody<AppLocalPreferences>(req);
sendJson(res, 200, await patchLocalPreferences(patch));
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}