Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
Q
qjclaw-dmg
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
AI-甘富林
qjclaw-dmg
Commits
8d5b8ec7
Commit
8d5b8ec7
authored
Mar 26, 2026
by
AI-甘富林
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(desktop): 增加每日工作上报
parent
0ab5ca4c
Changes
9
Hide whitespace changes
Inline
Side-by-side
Showing
9 changed files
with
912 additions
and
1 deletion
+912
-1
index.ts
apps/desktop/src/main/index.ts
+14
-0
ipc.ts
apps/desktop/src/main/ipc.ts
+6
-0
cloud-api.ts
apps/desktop/src/main/services/cloud-api.ts
+61
-0
daily-report-service.ts
apps/desktop/src/main/services/daily-report-service.ts
+390
-0
runtime-cloud-supervisor.ts
apps/desktop/src/main/services/runtime-cloud-supervisor.ts
+89
-0
smoke-cloud-api.ts
apps/desktop/src/main/services/smoke-cloud-api.ts
+18
-0
openclaw-daily-report-plan.md
docs/openclaw-daily-report-plan.md
+263
-0
云端API接入文档.txt
docs/云端API接入文档.txt
+44
-0
index.ts
packages/shared-types/src/index.ts
+27
-1
No files found.
apps/desktop/src/main/index.ts
View file @
8d5b8ec7
...
...
@@ -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();
});
apps/desktop/src/main/ipc.ts
View file @
8d5b8ec7
...
...
@@ -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 {
}
apps/desktop/src/main/services/cloud-api.ts
View file @
8d5b8ec7
...
...
@@ -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 {
apps/desktop/src/main/services/daily-report-service.ts
0 → 100644
View file @
8d5b8ec7
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
}
`
);
}
}
apps/desktop/src/main/services/runtime-cloud-supervisor.ts
View file @
8d5b8ec7
...
...
@@ -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 {
}
}
}
apps/desktop/src/main/services/smoke-cloud-api.ts
View file @
8d5b8ec7
...
...
@@ -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
});
};
}
docs/openclaw-daily-report-plan.md
0 → 100644
View file @
8d5b8ec7
# 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`
-
日报功能应保持独立,不能影响现有聊天链路
-
文档后续若要补充细节,可直接在这里扩展
docs/云端API接入文档.txt
View file @
8d5b8ec7
...
...
@@ -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
packages/shared-types/src/index.ts
View file @
8d5b8ec7
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
;
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment