Commit 8d5b8ec7 authored by AI-甘富林's avatar AI-甘富林

feat(desktop): 增加每日工作上报

parent 0ab5ca4c
......@@ -10,6 +10,7 @@ import { AppConfigService } from "./services/app-config.js";
import { AuthClient, CreditClient, ModelConfigClient, OpenClawConfigClient, ProfileClient } from "./services/cloud-api.js";
import { DeviceIdentityService } from "./services/device-identity.js";
import { DiagnosticsService } from "./services/diagnostics.js";
import { DailyReportService } from "./services/daily-report-service.js";
import { loadLocalOpenClawGatewayConfig, resolveEffectiveGatewayUrl } from "./services/openclaw-local-config.js";
import { SecretManager } from "./services/secrets.js";
import { startSmokeCloudApiServer } from "./services/smoke-cloud-api.js";
......@@ -515,6 +516,12 @@ async function bootstrap(): Promise<void> {
const creditClient = new CreditClient(configService, secretManager);
const skillClient = new SkillClient(skillStore);
const modelConfigClient = new ModelConfigClient(configService, secretManager);
const dailyReportService = new DailyReportService({
userDataPath: systemSummary.userDataPath,
configService,
secretManager
});
await dailyReportService.start();
const runtimeCloudSupervisor = new RuntimeCloudSupervisor({
appVersion: app.getVersion(),
configService,
......@@ -522,6 +529,9 @@ async function bootstrap(): Promise<void> {
runtimeManager,
secretManager
});
runtimeCloudSupervisor.onActivity((event) => {
dailyReportService.handleActivity(event);
});
if (resolveRequestedRuntimeMode(config.runtimeMode) !== "external-gateway" && (await secretManager.getApiKey())) {
await runtimeManager.start();
......@@ -551,6 +561,7 @@ async function bootstrap(): Promise<void> {
modelConfigClient,
runtimeCloudClient,
runtimeCloudSupervisor,
dailyReportService,
runtimeSkillBridge,
systemSummary,
localOpenClawConfig
......@@ -566,6 +577,7 @@ async function bootstrap(): Promise<void> {
event.preventDefault();
void (async () => {
await runtimeCloudSupervisor.stop("app-before-quit");
await dailyReportService.stop();
await runtimeManager.stop();
await runtimeSkillBridge.clearManagedSkills().catch(() => undefined);
if (stopSmokeCloudApiServer) {
......@@ -604,3 +616,5 @@ void bootstrap().catch(async (error) => {
}
app.quit();
});
......@@ -19,6 +19,7 @@ import type { RuntimeManager } from "@qjclaw/runtime-manager";
import type { AppConfigService } from "./services/app-config.js";
import type { AuthClient, CreditClient, ModelConfigClient, OpenClawConfigClient, ProfileClient } from "./services/cloud-api.js";
import type { DiagnosticsService } from "./services/diagnostics.js";
import type { DailyReportService } from "./services/daily-report-service.js";
import type { SkillClient } from "./services/skill-client.js";
import type { SkillStoreService } from "./services/skill-store.js";
import { resolveEffectiveGatewayUrl, type LocalOpenClawGatewayConfig } from "./services/openclaw-local-config.js";
......@@ -40,6 +41,7 @@ interface MainServices {
modelConfigClient: ModelConfigClient;
runtimeCloudClient: OpenClawConfigClient;
runtimeCloudSupervisor: RuntimeCloudSupervisor;
dailyReportService: DailyReportService;
runtimeSkillBridge: RuntimeSkillBridgeService;
appVersion: string;
systemSummary: SystemSummary;
......@@ -199,6 +201,7 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
modelConfigClient,
runtimeCloudClient,
runtimeCloudSupervisor,
dailyReportService,
runtimeSkillBridge,
systemSummary,
localOpenClawConfig
......@@ -456,6 +459,7 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
await reconfigureGatewayClient(config, input.gatewayToken);
await syncRuntimeCloudSupervisor("config-save");
void dailyReportService.runDueCheck().catch(() => undefined);
return getEffectiveConfig();
});
......@@ -667,6 +671,7 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
await reconfigureGatewayClient(config, input.gatewayToken);
await syncRuntimeCloudSupervisor("config-save");
void dailyReportService.runDueCheck().catch(() => undefined);
return getEffectiveConfig();
}
},
......@@ -738,3 +743,4 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
}
......@@ -4,6 +4,8 @@ import type {
AuthSessionSummary,
CreditSummary,
ModelCapability,
OpenClawDailyReportPayload,
OpenClawDailyReportResponse,
ModelConfigFallbackMode,
ModelConfigSummary,
ModelRecommendation,
......@@ -778,6 +780,64 @@ export class CreditClient {
}
}
export class OpenClawDailyReportClient {
private readonly configService: AppConfigService;
private readonly secretManager: SecretManager;
private readonly httpClient = new HttpJsonClient();
constructor(configService: AppConfigService, secretManager: SecretManager) {
this.configService = configService;
this.secretManager = secretManager;
}
async submit(payload: Omit<OpenClawDailyReportPayload, "api_key">): Promise<OpenClawDailyReportResponse> {
const config = await this.configService.load();
const baseUrl = config.runtimeCloudApiBaseUrl.trim().replace(/\/$/, "");
const apiKey = (await this.secretManager.getApiKey())?.trim();
if (!baseUrl) {
throw new Error("OpenClaw 运行时云端地址未配置。");
}
if (!apiKey) {
throw new Error("请先绑定 OpenClaw employee API Key。");
}
const url = new URL(`${baseUrl}/openclaw-daily-report`);
const body = await this.httpClient.request(url, {
method: "POST",
body: {
api_key: apiKey,
...payload
}
});
let response: {
ok?: boolean;
summary_id?: string;
employee_id?: string;
summary_date?: string;
};
try {
response = JSON.parse(body) as {
ok?: boolean;
summary_id?: string;
employee_id?: string;
summary_date?: string;
};
} catch {
throw new Error("OpenClaw 日报接口返回了无效 JSON。");
}
return {
ok: response.ok !== false,
summaryId: response.summary_id,
employeeId: response.employee_id,
summaryDate: response.summary_date ?? payload.summary_date
};
}
}
export class ModelConfigClient {
private readonly api: ProductCloudApiClient;
......@@ -792,3 +852,4 @@ export class ModelConfigClient {
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import type { DailyReportDeliveryState, OpenClawDailyReportPayload } from "@qjclaw/shared-types";
import { OpenClawDailyReportClient } from "./cloud-api.js";
import type { RuntimeCloudActivityEvent } from "./runtime-cloud-supervisor.js";
import type { AppConfigService } from "./app-config.js";
import type { SecretManager } from "./secrets.js";
interface PersistedDailyReportEntry {
summaryDate: string;
conversationIds: string[];
messageReceivedCount: number;
messageSentCount: number;
errorCount: number;
tokenDeltaTotal: number;
issueSamples: string[];
deliveryState: DailyReportDeliveryState;
lastAttemptAt?: string;
lastSentAt?: string;
lastError?: string;
updatedAt: string;
}
interface DailyReportPersistenceState {
reports: Record<string, PersistedDailyReportEntry>;
}
interface DailyReportServiceOptions {
userDataPath: string;
configService: AppConfigService;
secretManager: SecretManager;
}
interface ReportTime {
hour: number;
minute: number;
}
const DAILY_REPORT_DIR = "daily-reports";
const DAILY_REPORT_STATE_FILE = "state.json";
const DEFAULT_REPORT_TIME = "00:05";
const CHECK_INTERVAL_MS = 60 * 1000;
const MAX_ISSUES = 5;
const MAX_ISSUE_LENGTH = 240;
function formatLocalDate(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function getPreviousLocalDate(date: Date): string {
const previous = new Date(date);
previous.setDate(previous.getDate() - 1);
return formatLocalDate(previous);
}
function parseReportTime(raw?: string): ReportTime {
const trimmed = raw?.trim() || DEFAULT_REPORT_TIME;
const match = trimmed.match(/^(\d{1,2}):(\d{2})$/);
if (!match) {
return parseReportTime(DEFAULT_REPORT_TIME);
}
const hour = Number.parseInt(match[1], 10);
const minute = Number.parseInt(match[2], 10);
if (!Number.isInteger(hour) || !Number.isInteger(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
return parseReportTime(DEFAULT_REPORT_TIME);
}
return { hour, minute };
}
function hasReachedScheduledTime(date: Date, reportTime: ReportTime): boolean {
return date.getHours() > reportTime.hour
|| (date.getHours() === reportTime.hour && date.getMinutes() >= reportTime.minute);
}
function sanitizeIssue(errorType: string, message: string): string {
const compact = message.replace(/\s+/g, " ").trim();
const merged = `${errorType}: ${compact || "unknown error"}`;
return merged.length > MAX_ISSUE_LENGTH ? `${merged.slice(0, MAX_ISSUE_LENGTH - 3)}...` : merged;
}
function createEmptyEntry(summaryDate: string): PersistedDailyReportEntry {
return {
summaryDate,
conversationIds: [],
messageReceivedCount: 0,
messageSentCount: 0,
errorCount: 0,
tokenDeltaTotal: 0,
issueSamples: [],
deliveryState: "draft",
updatedAt: new Date().toISOString()
};
}
function normalizeEntry(summaryDate: string, value: unknown): PersistedDailyReportEntry {
const record = typeof value === "object" && value !== null ? value as Record<string, unknown> : {};
return {
summaryDate,
conversationIds: Array.isArray(record.conversationIds)
? record.conversationIds.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
: [],
messageReceivedCount: typeof record.messageReceivedCount === "number" ? record.messageReceivedCount : 0,
messageSentCount: typeof record.messageSentCount === "number" ? record.messageSentCount : 0,
errorCount: typeof record.errorCount === "number" ? record.errorCount : 0,
tokenDeltaTotal: typeof record.tokenDeltaTotal === "number" ? record.tokenDeltaTotal : 0,
issueSamples: Array.isArray(record.issueSamples)
? record.issueSamples.filter((item): item is string => typeof item === "string" && item.trim().length > 0).slice(0, MAX_ISSUES)
: [],
deliveryState: record.deliveryState === "sent" || record.deliveryState === "failed" ? record.deliveryState : "draft",
lastAttemptAt: typeof record.lastAttemptAt === "string" ? record.lastAttemptAt : undefined,
lastSentAt: typeof record.lastSentAt === "string" ? record.lastSentAt : undefined,
lastError: typeof record.lastError === "string" ? record.lastError : undefined,
updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : new Date().toISOString()
};
}
function normalizeState(value: unknown): DailyReportPersistenceState {
const record = typeof value === "object" && value !== null ? value as Record<string, unknown> : {};
const reportsRecord = typeof record.reports === "object" && record.reports !== null ? record.reports as Record<string, unknown> : {};
const reports: Record<string, PersistedDailyReportEntry> = {};
for (const [summaryDate, entry] of Object.entries(reportsRecord)) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(summaryDate)) {
continue;
}
reports[summaryDate] = normalizeEntry(summaryDate, entry);
}
return { reports };
}
export class DailyReportService {
private readonly configService: AppConfigService;
private readonly secretManager: SecretManager;
private readonly reportClient: OpenClawDailyReportClient;
private readonly reportsRoot: string;
private readonly statePath: string;
private readonly reportTime = parseReportTime(process.env.QJCLAW_DAILY_REPORT_TIME);
private state: DailyReportPersistenceState = { reports: {} };
private loadPromise?: Promise<void>;
private writeChain: Promise<void> = Promise.resolve();
private timer?: NodeJS.Timeout;
constructor(options: DailyReportServiceOptions) {
this.configService = options.configService;
this.secretManager = options.secretManager;
this.reportClient = new OpenClawDailyReportClient(options.configService, options.secretManager);
this.reportsRoot = path.join(options.userDataPath, DAILY_REPORT_DIR);
this.statePath = path.join(this.reportsRoot, DAILY_REPORT_STATE_FILE);
}
async start(): Promise<void> {
await this.ensureLoaded();
if (!this.timer) {
this.timer = setInterval(() => {
void this.runDueCheck().catch((error) => {
this.logError("Daily report scheduled check failed", error);
});
}, CHECK_INTERVAL_MS);
}
void this.runDueCheck().catch((error) => {
this.logError("Daily report startup check failed", error);
});
}
async stop(): Promise<void> {
if (this.timer) {
clearInterval(this.timer);
this.timer = undefined;
}
await this.writeChain;
}
async runDueCheck(now = new Date()): Promise<void> {
await this.ensureLoaded();
if (!hasReachedScheduledTime(now, this.reportTime)) {
return;
}
if (!await this.canSend()) {
return;
}
const summaryDate = getPreviousLocalDate(now);
const entry = this.state.reports[summaryDate];
if (!entry || entry.deliveryState === "sent") {
return;
}
await this.submitEntry(entry);
}
handleActivity(event: RuntimeCloudActivityEvent): void {
if (!this.loadPromise) {
return;
}
switch (event.type) {
case "message_received":
this.recordMessage(event.sessionId, event.occurredAt, "received");
return;
case "message_sent":
this.recordMessage(event.sessionId, event.occurredAt, "sent");
return;
case "error":
this.recordError(event.errorType, event.message, event.occurredAt, event.sessionId);
return;
case "heartbeat":
this.recordHeartbeat(event.occurredAt, event.billing?.tokenDelta);
return;
default:
return;
}
}
private async ensureLoaded(): Promise<void> {
if (!this.loadPromise) {
this.loadPromise = this.loadState();
}
await this.loadPromise;
}
private async loadState(): Promise<void> {
await mkdir(this.reportsRoot, { recursive: true });
try {
const raw = await readFile(this.statePath, "utf8");
this.state = normalizeState(JSON.parse(raw));
} catch {
this.state = { reports: {} };
await this.persistState();
}
}
private persistStateSoon(): void {
void this.persistState().catch((error) => {
this.logError("Daily report persist failed", error);
});
}
private recordMessage(sessionId: string, occurredAt: string, kind: "received" | "sent"): void {
const entry = this.getOrCreateEntry(formatLocalDate(new Date(occurredAt)));
if (sessionId && !entry.conversationIds.includes(sessionId)) {
entry.conversationIds.push(sessionId);
}
if (kind === "received") {
entry.messageReceivedCount += 1;
} else {
entry.messageSentCount += 1;
}
entry.updatedAt = new Date().toISOString();
this.persistStateSoon();
}
private recordError(errorType: string, message: string, occurredAt: string, sessionId?: string): void {
const entry = this.getOrCreateEntry(formatLocalDate(new Date(occurredAt)));
if (sessionId && !entry.conversationIds.includes(sessionId)) {
entry.conversationIds.push(sessionId);
}
entry.errorCount += 1;
const issue = sanitizeIssue(errorType, message);
if (!entry.issueSamples.includes(issue)) {
entry.issueSamples.push(issue);
if (entry.issueSamples.length > MAX_ISSUES) {
entry.issueSamples = entry.issueSamples.slice(-MAX_ISSUES);
}
}
entry.updatedAt = new Date().toISOString();
this.persistStateSoon();
}
private recordHeartbeat(occurredAt: string, tokenDelta?: number): void {
if (!Number.isFinite(tokenDelta)) {
return;
}
const entry = this.getOrCreateEntry(formatLocalDate(new Date(occurredAt)));
entry.tokenDeltaTotal += Number(tokenDelta ?? 0);
entry.updatedAt = new Date().toISOString();
this.persistStateSoon();
}
private getOrCreateEntry(summaryDate: string): PersistedDailyReportEntry {
const existing = this.state.reports[summaryDate];
if (existing) {
return existing;
}
const created = createEmptyEntry(summaryDate);
this.state.reports[summaryDate] = created;
return created;
}
private async submitEntry(entry: PersistedDailyReportEntry): Promise<void> {
const attemptedAt = new Date().toISOString();
entry.lastAttemptAt = attemptedAt;
entry.updatedAt = attemptedAt;
await this.persistState();
try {
const payload = this.buildPayload(entry);
await this.reportClient.submit(payload);
entry.deliveryState = "sent";
entry.lastSentAt = new Date().toISOString();
entry.lastError = undefined;
} catch (error) {
entry.deliveryState = "failed";
entry.lastError = error instanceof Error ? error.message : String(error);
this.logError(`Daily report submit failed for ${entry.summaryDate}`, error);
}
entry.updatedAt = new Date().toISOString();
await this.persistState();
}
private buildPayload(entry: PersistedDailyReportEntry): Omit<OpenClawDailyReportPayload, "api_key"> {
const totalConversations = entry.conversationIds.length;
const totalMessages = entry.messageReceivedCount + entry.messageSentCount;
const totalTokens = Math.max(0, Math.round(entry.tokenDeltaTotal));
const totalErrors = entry.errorCount;
const issues = entry.issueSamples.slice(0, MAX_ISSUES);
return {
summary_date: entry.summaryDate,
total_conversations: totalConversations,
total_messages: totalMessages,
total_tokens: totalTokens,
total_errors: totalErrors,
highlights: this.buildHighlights(totalConversations, totalMessages, totalTokens, totalErrors),
issues,
raw_summary_text: this.buildSummaryText(totalConversations, totalMessages, totalTokens, totalErrors),
active_channels: []
};
}
private buildHighlights(totalConversations: number, totalMessages: number, totalTokens: number, totalErrors: number): string[] {
const highlights = [`处理 ${totalConversations} 个会话,共 ${totalMessages} 条消息。`];
if (totalTokens > 0) {
highlights.push(`累计上报 ${totalTokens} 个 token。`);
}
highlights.push(totalErrors > 0 ? `记录到 ${totalErrors} 次异常。` : "未记录异常。");
return highlights;
}
private buildSummaryText(totalConversations: number, totalMessages: number, totalTokens: number, totalErrors: number): string {
const segments = [`当日共处理 ${totalConversations} 个会话`, `${totalMessages} 条消息`];
if (totalTokens > 0) {
segments.push(`累计 ${totalTokens} 个 token`);
}
segments.push(totalErrors > 0 ? `出现 ${totalErrors} 次异常` : "未记录异常");
return `${segments.join(",")}。`;
}
private async canSend(): Promise<boolean> {
const config = await this.configService.load();
const apiKey = (await this.secretManager.getApiKey())?.trim();
return Boolean(config.runtimeCloudApiBaseUrl.trim() && apiKey);
}
private async persistState(): Promise<void> {
const snapshot = JSON.stringify(this.state, null, 2);
const tempPath = `${this.statePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
this.writeChain = this.writeChain.catch(() => undefined).then(async () => {
await mkdir(this.reportsRoot, { recursive: true });
await writeFile(tempPath, snapshot, "utf8");
await unlink(this.statePath).catch(() => undefined);
await rename(tempPath, this.statePath);
});
await this.writeChain;
}
private logError(prefix: string, error: unknown): void {
const message = error instanceof Error ? error.message : String(error);
console.error(`${prefix}: ${message}`);
}
}
......@@ -23,6 +23,41 @@ interface RuntimeCloudEvent {
occurred_at: string;
}
export type RuntimeCloudActivityEvent =
| {
type: "message_received";
occurredAt: string;
sessionId: string;
prompt: string;
skillId?: string;
}
| {
type: "message_sent";
occurredAt: string;
sessionId: string;
replyContent: string;
modelId?: string;
skillId?: string;
}
| {
type: "error";
occurredAt: string;
errorType: string;
message: string;
modelId?: string;
sessionId?: string;
}
| {
type: "heartbeat";
occurredAt: string;
ok: boolean;
status?: string;
heartbeatAt?: string;
billing?: RuntimeHeartbeatBillingSummary;
};
type RuntimeCloudActivityListener = (event: RuntimeCloudActivityEvent) => Promise<void> | void;
interface OpenClawHeartbeatResponse {
ok?: boolean;
status?: string;
......@@ -206,6 +241,7 @@ export class RuntimeCloudSupervisor {
private readonly telemetry: RuntimeTelemetryStatus;
private readonly activeConversationIds = new Set<string>();
private readonly queue: RuntimeCloudEvent[] = [];
private readonly activityListeners = new Set<RuntimeCloudActivityListener>();
private heartbeatTimer?: NodeJS.Timeout;
private configSyncTimer?: NodeJS.Timeout;
private eventFlushTimer?: NodeJS.Timeout;
......@@ -249,6 +285,13 @@ export class RuntimeCloudSupervisor {
};
}
onActivity(listener: RuntimeCloudActivityListener): () => void {
this.activityListeners.add(listener);
return () => {
this.activityListeners.delete(listener);
};
}
async start(): Promise<RuntimeTelemetryStatus> {
if (this.telemetry.state === "running") {
return this.getStatus();
......@@ -329,6 +372,14 @@ export class RuntimeCloudSupervisor {
}
noteMessageReceived(sessionId: string, prompt: string, skillId?: string): void {
this.emitActivity({
type: "message_received",
occurredAt: new Date().toISOString(),
sessionId,
prompt,
skillId
});
if (this.telemetry.state !== "running") {
return;
}
......@@ -344,6 +395,15 @@ export class RuntimeCloudSupervisor {
}
noteMessageSent(sessionId: string, replyContent: string, modelId?: string, skillId?: string): void {
this.emitActivity({
type: "message_sent",
occurredAt: new Date().toISOString(),
sessionId,
replyContent,
modelId,
skillId
});
if (this.telemetry.state !== "running") {
return;
}
......@@ -360,6 +420,14 @@ export class RuntimeCloudSupervisor {
noteError(errorType: string, message: string, options?: { emitEvent?: boolean; modelId?: string; sessionId?: string }): void {
this.telemetry.errorCount += 1;
this.telemetry.lastError = message;
this.emitActivity({
type: "error",
occurredAt: new Date().toISOString(),
errorType,
message,
modelId: options?.modelId,
sessionId: options?.sessionId
});
if (options?.emitEvent === false || this.telemetry.state !== "running") {
return;
}
......@@ -379,6 +447,16 @@ export class RuntimeCloudSupervisor {
this.telemetry.activeConversationCount = this.activeConversationIds.size;
}
private emitActivity(event: RuntimeCloudActivityEvent): void {
for (const listener of this.activityListeners) {
try {
Promise.resolve(listener(event)).catch(() => undefined);
} catch {
// Keep runtime telemetry usable even if side observers fail.
}
}
}
private enqueueEvent(eventType: RuntimeCloudEventType, data?: Record<string, unknown>, conversationId?: string): void {
if (this.queue.length >= MAX_EVENT_QUEUE_SIZE) {
this.queue.shift();
......@@ -423,6 +501,14 @@ export class RuntimeCloudSupervisor {
this.telemetry.lastHeartbeatError = undefined;
this.telemetry.lastError = undefined;
this.telemetry.heartbeatSuccessCount += 1;
this.emitActivity({
type: "heartbeat",
occurredAt: new Date().toISOString(),
ok: response.ok,
status: response.status,
heartbeatAt: response.heartbeatAt,
billing: response.billing
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.telemetry.lastHeartbeatAt = new Date().toISOString();
......@@ -552,3 +638,6 @@ export class RuntimeCloudSupervisor {
}
}
}
......@@ -255,6 +255,22 @@ export async function startSmokeCloudApiServer(baseUrl: string, token: string, r
return;
}
if (req.method === "POST" && requestUrl.pathname === "/openclaw-daily-report") {
const body = await readJsonBody();
const apiKey = typeof body.api_key === "string" ? body.api_key : "";
if (apiKey !== runtimeApiKey) {
sendJson(401, { message: "Invalid api_key or employee not found" });
return;
}
sendJson(200, {
ok: true,
summary_id: "summary-" + Date.now(),
employee_id: "employee-smoke",
summary_date: typeof body.summary_date === "string" ? body.summary_date : new Date().toISOString().slice(0, 10)
});
return;
}
if (req.method === "GET" && requestUrl.pathname === "/openai/v1/models") {
if (bearerToken !== providerToken) {
sendJson(401, { message: "Invalid provider token." });
......@@ -412,3 +428,5 @@ export async function startSmokeCloudApiServer(baseUrl: string, token: string, r
});
};
}
# OpenClaw Daily Report 方案
## 1. 目标
为 OpenClaw 增加一个“每日自动汇总上报”能力:
- 每天自动整理当天的对话与工作情况
- 通过文档中定义的 `POST /openclaw-daily-report` 接口上报云端
- 不影响现有聊天、心跳、配置同步等主流程
- 方案适合测试阶段快速验证,也适合正式环境长期运行
## 2. 对接的云端 API
使用 `docs/云端API接入文档.txt` 中定义的接口:
- `POST /openclaw-daily-report`
### 请求字段
- `api_key`
- `summary_date`
- `total_conversations`
- `total_messages`
- `total_tokens`
- `total_errors`
- `highlights`
- `issues`
- `raw_summary_text`
- `active_channels`
### 返回字段
- `ok`
- `summary_id`
- `employee_id`
- `summary_date`
## 3. 数据来源
日报内容从现有桌面端数据里生成,主要来源如下:
- `packages/gateway-client/src/index.ts`
- 读取会话列表
- 读取会话消息
- `apps/desktop/src/main/ipc.ts`
- 现有聊天、会话、配置的主进程入口
- `apps/desktop/src/main/services/runtime-cloud-supervisor.ts`
- 现有主进程定时器、云端状态、运行时 telemetry 的管理模式
- `apps/desktop/src/main/services/cloud-api.ts`
- 云端请求封装与 `api_key` 读取方式
- `apps/desktop/src/main/services/diagnostics.ts`
- 结构化报告的组织方式,可参考其输出风格
## 4. 总体设计
### 4.1 单独做成定时任务
日报能力不挂到聊天发送链路,也不挂到心跳或配置同步链路里,而是单独做成一个主进程侧服务,例如:
- `DailyReportService`
这个服务只做三件事:
1. 到点检查是否需要发送日报
2. 生成日报 payload
3. 调用 `/openclaw-daily-report`
### 4.2 为什么要独立出来
这样可以保证:
- 日报失败不会影响聊天
- 日报失败不会影响心跳
- 日报失败不会影响配置同步
- 日报逻辑更容易测试和维护
## 5. 报表内容设计
建议日报保持“简洁 + 有要点”,避免发送过长原文。
### 5.1 建议 payload 结构
```ts
interface OpenClawDailyReportPayload {
api_key: string;
summary_date: string;
total_conversations: number;
total_messages: number;
total_tokens: number;
total_errors: number;
highlights: string[];
issues: string[];
raw_summary_text: string;
active_channels: Array<{
type: string;
name: string;
}>;
}
```
### 5.2 内容建议
- `summary_date`
- 使用本地日期,格式 `YYYY-MM-DD`
- `total_conversations`
- 当天会话数
- `total_messages`
- 当天消息总数
- `total_tokens`
- 如果当前链路能拿到 token,就统计累计值
- `total_errors`
- 当天错误数
- `highlights`
- 3~5 条关键摘要
- `issues`
- 异常、失败、恢复情况
- `raw_summary_text`
- 一段简短的人类可读总结
- `active_channels`
- 当天活跃的渠道列表
## 6. 调度策略
### 6.1 独立调度器
建议在主进程里单独维护一个定时器:
- 采用 `setInterval`
- 每分钟检查一次当前时间
- 如果当前时间已经到设定时间,且今天还没发过,就发送一次
### 6.2 时间配置
时间做成可配置项,便于测试和正式环境切换。
建议使用环境变量:
- `DAILY_REPORT_HOUR`
规则:
- 正式环境默认 `20`,即晚上 8 点
- 测试阶段可以改成当前小时或附近小时,方便快速触发
- 更改配置后,在下次启动时生效
### 6.3 去重机制
为了避免重复发送:
- 记录 `lastSummaryDate`
- 如果今天已经发过,就跳过
- 重启后也能继续判断,避免重复补发
## 7. 预期改动文件
### 7.1 核心文件
- `apps/desktop/src/main/services/cloud-api.ts`
- 增加 `POST /openclaw-daily-report` 的请求方法
- `apps/desktop/src/main/services/runtime-cloud-supervisor.ts`
- 增加日报生成与调度逻辑,或抽出一个新的主进程服务
- `apps/desktop/src/main/index.ts`
- 在应用启动时拉起日报服务
- `packages/shared-types/src/index.ts`
- 如有需要,补充日报请求/响应类型
### 7.2 可选文件
- `apps/desktop/src/main/ipc.ts`
- 如果需要手动触发测试,可增加一个 IPC 入口
## 8. 实现步骤
### Step 1:接入日报 API
`apps/desktop/src/main/services/cloud-api.ts` 中增加一个专门请求 `POST /openclaw-daily-report` 的方法。
要求:
- 复用现有 HTTP 请求封装
- 复用现有 `api_key` 获取方式
- 返回值结构化处理
### Step 2:生成日报内容
在主进程里读取当天会话数据,计算:
- 会话数
- 消息数
- token 数
- 错误数
- 亮点
- 问题
- 简短文本总结
- 活跃渠道
### Step 3:增加独立定时任务
新增一个主进程服务,例如 `DailyReportService`
- 启动时检查是否需要补发前一天的日报
- 每分钟检查一次时间
- 到点后发送日报
- 发送成功后记录 `lastSummaryDate`
### Step 4:保持主流程隔离
日报失败时:
- 只记录日志
- 不抛到聊天逻辑
- 不影响心跳和配置同步
## 9. 测试计划
### 9.1 代码级检查
```bash
corepack pnpm typecheck
corepack pnpm build
```
### 9.2 单元级检查
验证以下内容:
- 日报 payload 组装正确
- `summary_date` 格式正确
- 统计字段计算正确
- `lastSummaryDate` 能防止重复发送
- 请求失败不会影响聊天或 heartbeat
### 9.3 集成检查
1. 启动桌面端
2. 绑定 employee `api_key`
3. 设置 `DAILY_REPORT_HOUR` 为测试阶段可快速触发的时间
4. 创建几条会话和消息
5. 等定时器触发或手动触发日报
6. 确认 `/openclaw-daily-report` 收到正确 payload
7. 确认返回里有 `ok``summary_id``employee_id``summary_date`
### 9.4 回归检查
- 心跳是否仍正常执行
- 配置同步是否仍正常执行
- 聊天发送是否仍正常执行
- 重启后是否不会对同一天重复上报
### 9.5 失败路径检查
- 模拟接口返回 4xx / 5xx
- 确认不会影响主业务
- 确认日志里能看到失败原因
- 确认没有活动时的策略符合产品预期(跳过或发最小摘要)
## 10. 约定和注意事项
- 请求字段名必须严格按文档来,不要改成事件式字段名
- `raw_summary_text` 要简洁,不要过长
- `summary_date` 使用本地日期字符串 `YYYY-MM-DD`
- 日报功能应保持独立,不能影响现有聊天链路
- 文档后续若要补充细节,可直接在这里扩展
......@@ -738,4 +738,47 @@
}
]
}
}
工作日报上报
POST
/openclaw-daily-report
每天定时上报当日工作汇总,包含对话数、消息数、Token 用量、关键成果与异常。同一员工同一天重复上报会更新(upsert)。建议在每天 23:55 或次日凌晨上报。
请求示例
{
"api_key": "your_48char_hex_api_key",
"summary_date": "2026-03-23",
"total_conversations": 45,
"total_messages": 312,
"total_tokens": 186400,
"total_errors": 2,
"highlights": [
"处理了 3 个高优先级客诉",
"新增 12 个客户画像标签"
],
"issues": [
"飞书渠道 15:20 出现短暂连接中断"
],
"raw_summary_text": "今日共处理 45 个对话,主要集中在售后咨询和产品咨询。下午飞书渠道出现短暂中断,已自动恢复。",
"active_channels": [
{
"type": "feishu",
"name": "飞书客服"
},
{
"type": "wechat",
"name": "微信公众号"
}
]
}
响应示例
{
"ok": true,
"summary_id": "uuid",
"employee_id": "uuid",
"summary_date": "2026-03-23"
}
\ No newline at end of file
export const IPC_CHANNELS = {
export const IPC_CHANNELS = {
workspaceGetSummary: "workspace:get-summary",
gatewayStatus: "gateway:status",
gatewayConnect: "gateway:connect",
......@@ -55,6 +55,7 @@ export type RuntimeCloudEventType = "startup" | "shutdown" | "message_sent" | "m
export type PluginStatus = "included" | "extension" | "unavailable";
export type ChatLaunchState = "unbound" | "starting" | "ready" | "error";
export type SkillDownloadState = "pending" | "downloading" | "ready" | "failed" | "removed";
export type DailyReportDeliveryState = "draft" | "sent" | "failed";
export interface GatewayStatus {
state: GatewayState;
......@@ -163,6 +164,31 @@ export interface RuntimeHeartbeatBillingSummary {
balanceAfter?: number;
}
export interface OpenClawDailyReportChannelSummary {
type: string;
name: string;
}
export interface OpenClawDailyReportPayload {
api_key: string;
summary_date: string;
total_conversations: number;
total_messages: number;
total_tokens: number;
total_errors: number;
highlights: string[];
issues: string[];
raw_summary_text: string;
active_channels: OpenClawDailyReportChannelSummary[];
}
export interface OpenClawDailyReportResponse {
ok: boolean;
summaryId?: string;
employeeId?: string;
summaryDate: string;
}
export interface RuntimeTelemetryStatus {
state: RuntimeTelemetryState;
startedAt?: string;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment