feat: 实现日历提醒完整功能(操作执行、通知服务重构、归档)

- 新增 ReminderActionExecutor 处理取消/稍后提醒操作
- 新增 ReminderOutboxStore 本地存储待处理操作
- 重构 LocalNotificationService 支持聚合提醒和交互操作
- 新增 event_color_resolver 工具类统一颜色解析
- 新增 CalendarService.archiveEvent 归档方法
- 增强 ModelTracking 支持缓存命中、推理token和成本追踪
- 添加 qwen3.5-35b-a3b 模型配置
- 更新 AndroidManifest 全屏intent权限
- 补充相关单元测试和文档
This commit is contained in:
qzl
2026-03-18 19:12:47 +08:00
parent 257cb0f5d5
commit 00f37d7e19
35 changed files with 2676 additions and 244 deletions
+1
View File
@@ -76,6 +76,7 @@ Base URL: `/api/v1/agent`
- `200 OK`
- `Content-Type: text/event-stream`
- 事件类型与字段见 `docs/protocols/agent/sse-events.md`
- usage 审计与成本回退策略见 `docs/protocols/agent/sse-events.md`5) Usage 审计协议)
- 空闲时会发送 keep-alive 注释行 `: keep-alive`
### 错误码
+102 -2
View File
@@ -197,7 +197,107 @@ data: <json>
`inputTokens``outputTokens``cost``latencyMs``model` 属于后端内部统计字段,不在 SSE 对外协议中暴露。
### 3.5 快照事件
---
## 5) Usage 审计协议(后端内部)
本节描述后端对 LLM usage 的内部审计与计费策略。该协议用于数据库持久化、成本统计与运行观测,不对 SSE 外部协议直接暴露。
### 5.1 当前厂商范围
- DashScopeQwen
- DeepSeek
当前实现仅针对上述两家做深度适配。
### 5.2 原始字段采集(Provider -> Runtime
`TrackingChatModel` 会优先读取 provider 直接字段,读取不到时再从 metadata 补齐。
优先级如下:
1. 直接字段(优先)
- `usage.input_tokens`
- `usage.output_tokens`
- `usage.total_tokens`
- `usage.time`(秒)
- `usage.cost`(若存在)
2. metadata 字段(补齐)
- `metadata.prompt_tokens`
- `metadata.completion_tokens`
- `metadata.total_tokens`
- `metadata.prompt_tokens_details.cached_tokens`
- `metadata.prompt_cache_hit_tokens`
- `metadata.prompt_cache_miss_tokens`
- `metadata.completion_tokens_details.reasoning_tokens`
- `metadata.cost` / `metadata.total_cost`(若存在)
### 5.3 归一化后的内部 usage_summary 字段
`TrackingChatModel.usage_summary()` 当前输出:
- `input_tokens`
- `output_tokens`
- `total_tokens`
- `latency_ms`(由 `usage.time * 1000` 转换)
- `cached_prompt_tokens`
- `prompt_cache_hit_tokens`
- `prompt_cache_miss_tokens`
- `reasoning_tokens`
- `direct_cost`
- `direct_cost_observed`0/1
- `direct_cost_complete`0/1
- `model_call_records`
- `usage_records`
- `direct_cost_records`
- `cost_source``provider` | `catalog_fallback`
### 5.4 成本计算策略(严谨优先)
核心原则:**能直接用 provider 返回就直接用;缺失才 fallback。**
`LiteLLMService.build_usage_metadata()` 执行规则:
1. 仅当以下条件同时满足时使用 provider 直出成本:
- `usageComplete == true``model_call_records == usage_records`
- `direct_cost_observed == 1`
- `direct_cost_complete == 1`
- `direct_cost` 为有效非负数
2. 否则使用 catalog 价格回退计算(`calculate_cost`
### 5.5 Fallback 计费细节
- 档位选择:按 `prompt_tokens` 命中 `pricing_tiers.max_prompt_tokens`
- 公式:
```text
cost = uncached_prompt_tokens * input_cost_per_token
+ cached_prompt_tokens * cached_token_rate
+ completion_tokens * output_cost_per_token
```
- `cached_token_rate` 规则:
- 若 tier 配置了 `cache_hit_cost_per_token` 且 > 0,使用该值
- 否则回退为 `input_cost_per_token`
### 5.6 内部 costSource 语义
- `provider`: 使用 provider 直接成本
- `catalog_fallback`: 正常使用价格表回退
- `catalog_fallback_incomplete_provider_cost`: provider 返回了部分 direct cost,但不完整,回退价格表
- `incomplete_usage_fallback`: usage 本身不完整,回退价格表
### 5.7 DeepSeek / DashScope 当前观测到的返回特征
根据当前线上探针与运行结果:
- 两家都稳定返回:`input_tokens``output_tokens``time`
- `usage.total_tokens` 顶层可能为空,但 `metadata.total_tokens` 可用
- DeepSeek 常见 `prompt_tokens_details.cached_tokens``prompt_cache_hit_tokens``prompt_cache_miss_tokens`
- DashScope 常见 `completion_tokens_details.reasoning_tokens`(可能为 `null`
- 两家当前都未稳定提供直接 `cost` 字段,因此多数场景为 catalog fallback
## 6) 快照事件
编码器支持以下 AG-UI 类型映射:
@@ -208,7 +308,7 @@ data: <json>
---
## 4) 字段命名约定
## 7) 字段命名约定
- 事件顶层通用字段使用 AG-UI 风格:`type``threadId``runId`
- 部分业务字段沿运行时模型历史命名保留下划线:
@@ -0,0 +1,143 @@
# Calendar Reminder Alert Lifecycle Protocol
## Version
- Current: `1.0`
- Status: Draft
---
## Goal
定义日程提醒弹窗在 Android/iOS 的统一行为语义,覆盖提醒触发、用户操作、超时、离线补偿、归档与重装恢复,确保多端一致性和可恢复性。
---
## Canonical Rules
1. 提醒弹窗动作语义跨平台一致:`cancel``snooze_10m``timeout_30s`
2. `timeout_30s` 语义固定为忽略当前弹窗并按 10 分钟后再次提醒,不直接归档。
3. `cancel` 必须归档对应日程(`status=archived`)。
4. 到达事件结束时间后(`now >= end_at`)必须停止提醒并归档。
5. 归档后的 UI 渲染必须灰色显示;不强制改写原始 metadata 颜色。
6. 前端动作上报后端采用最终一致性:本地 outbox + 重放机制。
---
## Reminder Payload Contract
```json
{
"eventId": "uuid",
"title": "string",
"startAt": "iso8601-with-offset",
"endAt": "iso8601-with-offset|null",
"timezone": "IANA",
"location": "string|null",
"notes": "string|null",
"color": "#RRGGBB|null",
"mode": "single|aggregate",
"aggregateIds": ["uuid"],
"version": 1
}
```
### Constraints
- `eventId` 必填且为 UUID。
- `startAt` 必须带时区偏移。
- `mode=aggregate` 时,`aggregateIds` 至少包含 2 个 id。
- `version` 必填,用于后续协议升级兼容。
---
## Action Contract
动作枚举:
- `cancel`: 用户取消提醒并归档事件
- `snooze_10m`: 用户点击稍后提醒,重排到 `now + 10m`
- `timeout_30s`: 用户 30s 未处理,按 `snooze_10m` 处理
- `auto_archive`: 系统判定事件已过期,自动归档
---
## Outbox Contract (Frontend)
```json
{
"opId": "uuid",
"eventId": "uuid",
"action": "cancel|snooze_10m|timeout_30s|auto_archive",
"targetStatus": "archived|null",
"occurredAt": "iso8601-with-offset",
"retryCount": 0,
"nextRetryAt": "iso8601-with-offset|null",
"state": "pending|done|dead",
"lastError": "string|null"
}
```
### Constraints
- 幂等键:`(eventId, action, occurredAtBucket)`
- 重试策略:指数退避,最大重试次数可配置。
- `cancel``auto_archive` 都映射到后端 `PATCH status=archived`
- Outbox 记录必须本地持久化,App 重启后可恢复。
---
## Scheduling and Compensation Rules
### Normal schedule
- `remindAt = startAt - reminderMinutes`
### Bootstrap/reinstall compensation
启动重建时对每个 active 事件执行:
1. `now < remindAt`:按 `remindAt` 正常调度。
2. `remindAt <= now < endAt`:立刻补偿提醒(建议 `+5s`),然后进入 10 分钟节奏。
3. `now >= endAt`:不再提醒,走归档流程。
---
## Uniqueness and Dedupe Rules
- 通知唯一键:`hash(eventId + cycleStartEpochMinutes + mode)`
- 每次创建提醒前必须取消同 dedupe key 的旧提醒(upsert 语义)。
- 补偿提醒在同一 cycle 窗口内最多触发一次。
- 启动恢复时要同时参考 pending notification 和 outbox 状态,避免重复调度。
---
## Overlap Rule
- 同一分钟内多个提醒合并为一个 aggregate 弹窗。
- aggregate 弹窗默认操作作用于全部成员事件。
- aggregate 负载中必须包含 `aggregateIds` 以支持后续批处理。
---
## Backend Contract Reuse
- 复用现有接口:`PATCH /schedule-items/{item_id}`,请求体传 `{"status":"archived"}`
- 建议提供/改造 overlap 查询语义用于启动补偿:
- `start_at <= window_end`
- `end_at IS NULL OR end_at >= window_start`
- `status=active`
---
## Platform Notes
### Android
- 优先 full-screen intent;系统可能因策略降级为 heads-up/横幅。
- 声音和振动受通知通道及系统设置影响。
### iOS
- 支持动作按钮与本地提醒语义。
- 不保证 Android 式强制全屏闹钟弹窗;以锁屏/横幅提醒为主。