docs: 更新协议文档,删除废弃计划文档

- 更新 http-error-codes, user-points-chat-data-protocol
- 更新 divination-run-protocol, profile-protocol
- 删除废弃的后端和前端设计计划文档
This commit is contained in:
qzl
2026-04-08 17:23:02 +08:00
parent 49fc9a116f
commit e80a82bef4
57 changed files with 4117 additions and 2269 deletions
@@ -0,0 +1,89 @@
# Bug: 追问1次后无法进入查看历史记录
日期:2026-04-08
状态:已确认(未修复)
## 问题描述
追问1次之后追问入口关闭,但用户也没法点击进去查看追问的历史记录。
## 根因分析
`_loadFollowUpEligibility()` 方法(`divination_result_screen.dart:65-83`)将两个概念混为一谈:
```dart
Future<void> _loadFollowUpEligibility() async {
...
final messages = await widget.divinationApi!.getSessionMessages(
threadId: widget.data.threadId!,
);
final userCount = messages.where((msg) => msg.role == 'user').length;
...
setState(() {
_canFollowUp = userCount < 2; // 同时控制"能追问"和"能进入"
...
});
}
```
按钮逻辑(`divination_result_screen.dart:367`):
```dart
onPressed: (!_canFollowUp || _followUpEligibilityLoading)
? null // _canFollowUp = false 时按钮完全禁用
: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => FollowUpChatScreen(...),
),
);
},
```
**问题**
- `_canFollowUp = userCount < 2` 控制的是"是否还有追问配额"
-`onPressed` 把它和"是否能进入查看历史"混用了
- 导致用户追问1次后(userCount=2),`_canFollowUp=false`,按钮被禁用
- 用户**无法进入追问页面查看历史记录**
**业务逻辑分析**
- 每 session 最多2次追问(首问1次 + 追问1次)
- 追问1次后,用户不能再发送新追问
- 但**应该仍能进入查看历史记录**
### 正确的逻辑应该是
```dart
// 追问配额判断
_canFollowUp = userCount < 2;
// 能否进入查看历史(与追问次数无关,只要有历史消息就能进入)
_canEnterFollowUpChat = messages.isNotEmpty; // 或者只要有 threadId 就能进
// 按钮文案
buttonText = _canFollowUp ? l10n.followUpEntryHint : l10n.followUpViewHistory;
// 按钮是否禁用
onPressed = (_followUpEligibilityLoading)
? null // 只在加载中时禁用
: () { ... } // 始终可点击进入查看
```
## 证据
- 结果页代码:`apps/lib/features/divination/presentation/screens/divination_result_screen.dart`
- `_buildFollowUpBar`: 行 338-394
- `_loadFollowUpEligibility`: 行 65-83
- 按钮 onPress: 行 367
- 追问聊天页:`apps/lib/features/divination/presentation/screens/follow_up_chat_screen.dart`
## 修复方向
1. 分离"能否追问"和"能否进入查看历史"的逻辑
2. 按钮文案根据状态显示不同内容(能追问显示追问提示,已用完显示"查看历史")
3. 只要有 `threadId`,用户就应该能进入查看历史
---
## 相关文档
- 随访工程计划:`docs/plans/2026-04-08-followup-session-history-eng-plan.md`
@@ -0,0 +1,261 @@
# Bug: 追问页面 UI 问题
日期:2026-04-08
状态:已确认(未修复)
## Bug 1: 追问页发送时顶部和消息下方重复加载UI
### 问题描述
在追问页面,用户输入信息发送后,顶部和消息下方同时出现加载UI,但顶部的加载UI是多余的。
### 根因分析
**文件**`apps/lib/features/divination/presentation/screens/follow_up_chat_screen.dart`
**问题代码**
1. 顶部 step 指示器(第 85-124 行):
```dart
if (_sending && _currentStepName != null)
Container(
// 显示 step 进度,如 "解读中..."、"推理中..."
child: Row(
children: [
CircularProgressIndicator(...),
Text(_stepLabel(_currentStepName!)),
],
),
)
```
2. 消息下方 streaming placeholder(第 565-584 行):
```dart
isStreamingPlaceholder
? Row(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(...),
Text(l10n.followUpGenerating), // "生成中..."
],
)
```
**事件触发流程**
| 顺序 | 事件 | 顶部指示器 | 消息下方 |
|------|------|----------|---------|
| 1 | `_submitText()` 调用 | 无 | streaming placeholder 显示 "生成中..." |
| 2 | `STEP_STARTED(stepName='divination')` | 显示 "解读中..." | 继续显示 |
| 3 | `STEP_FINISHED` | 消失 | 继续显示 |
| 4 | `STEP_STARTED(stepName='worker')` | 显示 "推理中..." | **同时显示** "生成中..." |
| 5 | `STEP_FINISHED` | 消失 | 继续显示 |
| 6 | `TEXT_MESSAGE_END` | 无 | placeholder 消失,显示真实内容 |
**问题**:在 worker 阶段(步骤 4),顶部显示 "推理中..." 同时消息下方显示 "生成中...",造成重复反馈。用户只需要一个加载反馈即可。
**修复方向**
1. **方案A**:只在 divination 阶段显示顶部 step 指示器,worker 阶段隐藏(因为 streaming placeholder 已经提供反馈)
```dart
// 第 85 行修改
if (_sending && _currentStepName != null && _currentStepName != 'worker')
```
2. **方案B**:移除顶部 step 指示器,完全依赖 streaming placeholder
3. **方案C**:在 worker 阶段隐藏 streaming placeholder,只显示顶部指示器
推荐 **方案A**,因为:
- divination 阶段没有 streaming placeholder,需要顶部指示器提供反馈
- worker 阶段有 streaming placeholder,不需要顶部指示器
- 改动最小,只加一个条件
---
## Bug 2: 语音模式录制时缺少动画UI
### 问题描述
根据 social-app 的实现,按住说话模式下录音时应该有动画效果,但 eryao 的实现只有文字,没有动画。
### 根因分析
**对比**
| 功能 | social-app | eryao |
|------|------------|-------|
| 录音中动画 | `recordingAnimation` widget(外部传入) | 只有文字 "录音中..." |
| 录音提示文字 | 带动画的 widget | 只有文字 |
| 录音中隐藏输入区域 | `IgnorePointer` + `Opacity` | 未实现 |
**eryao 当前实现**`message_composer.dart:159-172`):
```dart
if (_isRecording) {
if (!showRecordingInlineFeedback) {
return Text(recordingText, ...);
}
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(width: 16, height: 16), // 空白占位
const SizedBox(height: AppSpacing.xs),
Text(recordingText, ...), // "录音中..."
const SizedBox(height: AppSpacing.xs),
Text(recordingHintText, ...), // "上滑取消"
],
);
}
```
**social-app 实现**`message_composer.dart:222-239`):
```dart
if (_isRecording) {
if (!showRecordingInlineFeedback) {
return Text(resolvedRecordingText, ...);
}
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
recordingAnimation, // 动画widget(外部传入)
const SizedBox(height: AppSpacing.xs),
Text(resolvedRecordingText, ...),
const SizedBox(height: AppSpacing.xs),
Text(resolvedRecordingHintText, ...),
],
);
}
```
**差异**
1. `recordingAnimation` widget 缺失 - 需要外部传入,eryao 直接用空白占位
2. 录音中图标/按钮没有动画效果
**修复方向**
1. 在 `FollowUpChatScreen` 或其父组件中创建 `recordingAnimation` widget
2. 将 `recordingAnimation` 通过 `MessageComposer` 传入
3. 参考 social-app 的实现,使用脉冲动画或波形动画
**参考实现**(需要添加 `recording_animation.dart`):
```dart
class RecordingAnimation extends StatefulWidget {
final double size;
final Color color;
@override
State<RecordingAnimation> createState() => _RecordingAnimationState();
}
class _RecordingAnimationState extends State<RecordingAnimation>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 1000),
vsync: this,
)..repeat(reverse: true);
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.2).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _scaleAnimation,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Container(
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: widget.color.withValues(alpha: 0.3),
),
child: Icon(Icons.mic, color: widget.color, size: widget.size * 0.6),
),
);
},
);
}
}
```
---
## Bug 2 & 3: 直接复用 social-app 输入框(不需要 plus 按钮)
### 需求
**直接复用** social-app 的 `MessageComposer`,但**不需要 plus 按钮**。
### 复用清单
| 功能 | social-app | eryao | 处理 |
|------|------------|-------|------|
| `recordingAnimation` | required Widget | 空白占位 | **需要添加** |
| 双层阴影 | 有 | 无 | **需要添加** |
| `AppRadius.xxl` | 用于圆角 | 无此值 | **需要添加** |
| Plus 按钮 | 有 | 无 | **不需要**,保持无 |
| 图标 | `LucideIcons` | Material Icons | 保持 Material Icons |
### 实现步骤
#### 1. 添加 `AppRadius.xxl` 到 `design_tokens.dart`
```dart
class AppRadius {
static const double sm = 8;
static const double md = 12;
static const double lg = 16;
static const double xl = 20;
static const double xxl = 32; // 新增
static const double full = 999;
}
```
#### 2. 创建 `RecordingAnimation` widget
放在 `apps/lib/shared/widgets/` 下,参考 social-app 的脉冲动画效果。
#### 3. 修改 `MessageComposer`
- 添加 `recordingAnimation` 参数(required
- 添加双层阴影
- 使用 `AppRadius.xxl` 替代 `AppRadius.full`
- 移除 plus 按钮(eryao 原有的无 plus 逻辑保持不变)
- 保持 Material Icons
#### 4. 修改 `FollowUpChatScreen`
- 创建 `RecordingAnimation` 实例
- 传入 `MessageComposer`
---
## 相关文件
- `apps/lib/features/divination/presentation/screens/follow_up_chat_screen.dart`
- `apps/lib/shared/widgets/message_composer.dart`
- `apps/lib/shared/widgets/recording_animation.dart`(新建)
- `apps/lib/shared/theme/design_tokens.dart`
- `/home/qzl/Code/social-app/apps/lib/shared/widgets/message_composer.dart`
## 修复优先级
1. **高优先级**Bug 1(重复加载UI
2. **高优先级**Bug 2 & 3(直接复用 social-app 输入框)
@@ -0,0 +1,200 @@
# Bug: 追问时 agent_output 被重新生成,导致 sign_level 被覆盖
日期:2026-04-08
状态:已确认根因(未修复)
## 问题描述
首次解卦完成后,用户继续追问时,agent 会重新生成 `agent_output`,重新计算卦的结论和标签。
**具体表现**
- 首次解卦结论:`中下签`
- 追问后结论:`下下签`(同一个卦,结果被重新生成)
## 根因分析
**问题定位**`backend/src/core/agentscope/runtime/runner.py`
### 根因链条
1. **`runner.py:execute()` 方法(第 70-133 行)**
无论 `runtime_mode``chat` 还是 `follow_up`,都会执行以下逻辑:
```python
# 第 105-119 行:始终推导卦象
derived_divination = self._resolve_derived_divination(run_input=run_input)
await self._emit_step_event(
pipeline=pipeline,
run_input=run_input,
step_name="divination",
event_type="DIVINATION_DERIVED", # <-- 追问时不应发射此事件
...
)
```
2. **`runner.py:_execute_worker_step()` 方法(第 200-245 行)**
始终将 `derived_divination` 传递给 worker
```python
# 第 294-302 行:始终将 divination_derived 放入 worker_output
await emitter.emit_final_text_end(
worker_output={
**worker_payload.model_dump(mode="json", exclude_none=True),
"divination_derived": derived_divination.model_dump(...), # <-- 追问时不应包含
},
...
)
```
3. **`stage_emitter.py:emit_final_text_end()` 方法(第 46-73 行)**
始终将所有字段放入 `TEXT_MESSAGE_END` 事件:
```python
payload = {
"messageId": message_id,
"role": "assistant",
"stage": self._stage,
"status": worker_output.get("status"),
"sign_level": worker_output.get("sign_level"), # <-- 追问时不应有
"conclusion": worker_output.get("conclusion", []), # <-- 追问时不应有
"focus_points": worker_output.get("focus_points", []),
"advice": worker_output.get("advice", []),
"keywords": worker_output.get("keywords", []),
"answer": worker_output.get("answer", ""),
"error": worker_output.get("error"),
"divination_derived": worker_output.get("divination_derived"), # <-- 追问时不应有
...
}
```
4. **`store.py:_persist_text_message()` 方法(第 125-160 行)**
从事件中提取所有字段并完整存储:
```python
worker_output_fields = (
"status",
"sign_level", # <-- 追问时被重新生成并覆盖
"conclusion", # <-- 追问时被重新生成并覆盖
"focus_points",
"advice",
"keywords",
"answer",
"error",
"divination_derived", # <-- 追问时被重新生成
"ui_hints",
)
```
### 问题本质
`runtime_mode=follow_up` 时,系统仍在:
1. 重新推导卦象(调用 `_resolve_derived_divination`
2. 发射完整的 `DIVINATION_DERIVED` 事件
3. 生成包含所有结构化字段的 `worker_output`
4. 将所有字段存储到数据库
但根据工程计划(`docs/plans/2026-04-08-followup-session-history-eng-plan.md:37-40`),追问时的预期行为是:
```
[一次追问]
user -> /agent/runs(runtime_mode=follow_up)
-> assistant(content [+ optional metadata.agent_output.answer])
```
即:追问时只输出 `answer`(内容),不重新生成卦象结构。
## 证据
### 数据库证据
Session `015fe0f9-0500-43ab-911a-4ce8e3160032`
| 时间 | 角色 | sign_level | divination_derived | 问题 |
|------|------|------------|-------------------|------|
| 05:21:19 | user | - | - | 首问问题 |
| 05:21:28 | assistant | 中下签 | 完整 | 首答(正确) |
| 05:22:24 | user | - | - | 追问 |
| 05:22:33 | assistant | **下下签** | 完整 | 追问答(sign_level 被重新生成) |
两个 assistant 消息的 `divination_derived` 完全相同,但 `sign_level` 不同,证明是重新生成的。
## 修复方向
### 1. `runner.py`
在 `execute()` 方法中,根据 `runtime_mode` 决定是否推导卦象:
```python
async def execute(self, ...):
runtime_mode = self._resolve_runtime_mode(run_input=run_input)
if runtime_mode == RuntimeMode.CHAT:
derived_divination = self._resolve_derived_divination(run_input=run_input)
await self._emit_step_event(...) # DIVINATION_DERIVED
else:
derived_divination = None # follow_up 不推导
```
### 2. `stage_emitter.py`
`emit_final_text_end()` 根据 `runtime_mode` 决定发送哪些字段:
```python
async def emit_final_text_end(self, ..., runtime_mode: str):
payload = {"messageId": ..., "role": "assistant", "stage": self._stage}
if runtime_mode == "chat":
payload.update({
"status": worker_output.get("status"),
"sign_level": worker_output.get("sign_level"),
"conclusion": worker_output.get("conclusion", []),
"focus_points": worker_output.get("focus_points", []),
"advice": worker_output.get("advice", []),
"keywords": worker_output.get("keywords", []),
"divination_derived": worker_output.get("divination_derived"),
...
})
else: # follow_up
payload["answer"] = worker_output.get("answer", "")
```
### 3. `store.py`
`_persist_text_message()` 根据 `runtime_mode` 决定提取哪些字段:
```python
async def _persist_text_message(self, ...):
runtime_mode = self._resolve_runtime_mode(event=event)
if runtime_mode == "chat":
worker_output_fields = ("status", "sign_level", "conclusion", ...)
else: # follow_up
worker_output_fields = ("answer",) # 只存储 answer
```
### 4. `runtime_models.py`
`resolve_worker_output_model()` 应根据 `runtime_mode` 返回不同 schema
```python
def resolve_worker_output_model(runtime_mode: RuntimeMode = RuntimeMode.CHAT) -> type[WorkerAgentOutputLite]:
if runtime_mode == RuntimeMode.FOLLOW_UP:
return WorkerAgentOutputLite # 只有 answer
return AgentOutput # 完整结构(继承自 WorkerAgentOutputRich
```
## 相关文件
- `backend/src/core/agentscope/runtime/runner.py` - 主编排逻辑
- `backend/src/core/agentscope/runtime/stage_emitter.py` - 事件发射
- `backend/src/core/agentscope/events/store.py` - 事件持久化
- `backend/src/schemas/agent/runtime_models.py` - 输出 schema 定义
## 相关文档
- 工程计划:`docs/plans/2026-04-08-followup-session-history-eng-plan.md`
- 协议文档:`docs/protocols/divination/divination-run-protocol.md`
@@ -0,0 +1,90 @@
# Bug: 首页历史 Session 只显示4条,"更多"按钮缺失
日期:2026-04-08
状态:功能缺失(未完成)
## 问题描述
前端主页接收后端传来的历史 session,永远只显示四个,"more"按钮不见了。
## 根因分析
### 1. 前端硬编码 `take(4)`
`apps/lib/features/home/presentation/screens/home_screen.dart:287`
```dart
children: historyItems.take(4).map((item) {
```
这是在 commit `6e82053`(重构首页为底部导航栏布局)时**有意添加**的设计选择,用于限制首页展示的历史记录数量。
### 2. 前端未实现"更多"按钮
- `l10n.more` 本地化字符串存在(`'更多'`/`'More'`),定义于 `apps/lib/l10n/app_localizations_zh.dart:115``app_localizations_en.dart:116`
- 但搜索整个 Dart 代码库,`l10n.more` **没有任何地方使用它**
- "更多"功能从未被实现
### 3. 后端 `hasMore` 硬编码为 `False`
`backend/src/v1/agent/service.py:655-659`
```python
return HistorySnapshotResponse(
scope="history_sessions_latest_assistant",
threadId=None,
day=None,
hasMore=False, # 硬编码,未实际计算
messages=messages,
)
```
后端 schema 定义了 `hasMore` 字段(`schemas.py:238`),但 service 层返回时**硬编码为 `False`**,从未实际计算是否还有更多数据。
### 4. 后端 API 行为正确
`backend/src/v1/agent/service.py:641-646`
```python
raw_messages = await self._repository.get_latest_assistant_messages_by_user_sessions(
user_id=str(current_user.id),
visibility_mask=bit_mask(bit=int(SystemVisibilityBit.UI_HISTORY)),
session_limit=50, # 后端返回最多50条
)
```
后端正确返回最多 50 条历史 session,前端只是没有利用这些数据。
## 问题性质
| 层级 | 现象 | 性质 |
|------|------|------|
| 后端 | `hasMore=False` 硬编码 | 缺陷:响应语义不正确 |
| 前端 | `take(4)` 只显示4条 | 设计选择:有意的 UI 限制 |
| 前端 | 无"更多"按钮 | 功能缺失:有 `l10n.more` 但未实现 |
## 证据
- 前端代码:`apps/lib/features/home/presentation/screens/home_screen.dart:287`
- 后端代码:`backend/src/v1/agent/service.py:655`
- 本地化字符串:`apps/lib/l10n/app_localizations_zh.dart:115`
- 提交记录:`6e82053`feat(home): 重构首页为底部导航栏布局)
## 修复方向
### 方案 A:实现完整分页(推荐)
1. **后端**:实现真正的 `hasMore` 计算逻辑
- 添加 `offset` 参数支持分页
- 实际计算 `hasMore = total_count > offset + limit`
2. **前端**:实现"更多"按钮或无限滚动
- 监听滚动位置,滚动到底部时加载更多
- 或添加"更多"按钮手动触发加载
### 方案 B:移除不存在的字符串
如果业务确定只需要显示4条,则:
- 移除 `l10n.more` 本地化字符串,避免误导
## 相关文档
- 工程计划:`docs/plans/2026-04-05-divination-history-profile-eng-plan.md`
- 随访计划:`docs/plans/2026-04-08-followup-session-history-eng-plan.md`