# 语音助手回拨功能实现方案

## 一、需求来源

在 8b8a0664（Dbus 联动：助手启动语音通话/助手-语音进程联动退出/语音窗口挂断周期变更）已有实现的基础上，新增"回拨"功能：

| 需求编号 | 描述 |
|---------|------|
| 1 | 用户提问后 AI 正在回答时点挂断，窗口隐藏但 AI 在后台继续处理 |
| 2 | AI 回答完成后自动弹出"回拨"界面通知用户 |
| 3 | 回拨界面：widget_voice_logo 被替换为 "KylinBot" 标签，tip 显示"邀请你语音聊天..."，底部 MiniPanel 仅显示红色挂断+绿色接听两个按钮 |
| 4 | 回拨界面点挂断 → 视为用户已知晓通知，取消后续重试并隐藏窗口 |
| 5 | 回拨界面点接听 → 恢复正常 UI，按下字幕按钮显示会话页面，启动 ASR |
| 6 | 回拨只在挂断（hide）后触发，关闭按钮（quit）不触发 |
| 7 | 绿色接听按钮为新控件，需参照挂断按钮样式实现 |

## 二、改动前现状分析

### 已实现

- `pushButton_cutoff` 挂断按钮：点击后 `stopAudioForHide()` + `hide()`，进程保留
- `closeButton` 关闭按钮：点击后 `shutdownAndQuit()`，进程彻底退出
- `onShowWindow()`：通过 D-Bus `ShowWindow()` 唤起隐藏窗口，恢复窗口 + 启动 ASR
- MiniPanel 包含 4 个按钮：屏幕共享、字幕、静音、挂断
- `widget_voice_logo`（VoiceWaveWidget）显示语音波动画
- `label_tip` 显示当前状态文本

### 未实现/有问题

1. **挂断后 AI 工作丢失**：挂断时无论 AI 是否在处理，都直接 `stopAudioForHide()` + `hide()`，Gateway 请求被中断后无法通知用户
2. **无回拨通知**：用户挂断后再无途径获知 AI 回答结果
3. **无接听按钮**：MiniPanel 没有接听控件
4. **无"回拨"状态**：MainWindow 没有回拨模式的概念，无法区分正常通话与回拨场景

## 三、设计方案

### 3.1 回拨触发条件

**核心逻辑**：回拨只在 AI 正在处理（Processing/Answering）时用户点挂断才触发。

```
用户提问 → AI 正在回答(Processing/Answering) → 用户点挂断 → 窗口隐藏
    → 但不取消 Gateway，AI 在后台继续处理
    → AI 回答完成(handleAiReplyFinished) → 自动弹出回拨界面并开始10秒振铃
    → 用户点接听 → 恢复正常UI，显示会话页面
    → 用户点挂断 → 取消后续重试并隐藏窗口
```

**关键状态变量**：

- `m_callbackPending`：挂断时 AI 仍在工作，标记"等待回拨"
- `m_callbackMode`：当前处于回拨界面

### 3.2 cutoffClicked 分流

```
用户点击挂断按钮
  ├─ m_callbackMode == true → cancelCallbackFlow(), hide()（用户主动挂断回拨，不再重试）
  ├─ m_state == Processing/Answering → stopAudioForHide(), m_callbackPending=true, hide()
  └─ 其他状态 → stopAudioForHide(), hide()（原有逻辑）
```

**决策**：AI 工作中挂断时调用 `stopAudioForHide()`（停 ASR/TTS、重置语音状态）但不调用 `m_gatewayClient->cancelResponse()`，让 Gateway 继续后台处理。

### 3.3 回拨界面 UI

**widget_voice_logo 区域**：

| 正常模式 | 回拨模式 |
|---------|---------|
| `widget_voice_logo`（VoiceWaveWidget 语音波动画）显示 | `widget_voice_logo` 隐藏 |
| `label_callback_title` 隐藏 | `label_callback_title` 显示（73×27px, "KylinBot", Noto Sans CJK SC 18px 500, rgba(0,0,0,0.85)） |
| `label_tip` 显示 "Listening"/"Muted" | `label_tip` 显示 "Inviting you to voice chat..." |

**底部 MiniPanel**：

| 正常模式 | 回拨模式 |
|---------|---------|
| 屏幕共享 + 字幕 + 静音 + 挂断（4按钮，margins 24px） | 挂断 + 接听（2按钮，margins 88px，spacing 16px，居中） |

### 3.4 接听按钮样式

参照挂断按钮实现，样式定义：

```
默认：background-color: #00B42A, border-radius: 24px, border: transparent
Hover：background-color: #00AB28
Pressed：background-color: #009022
Icon：call-start-symbolic.svg
Size：48×48px
```

### 3.5 setCallbackMode 切换

```cpp
void MainWindow::setCallbackMode(bool enabled)
{
    if (enabled) {
        m_callbackMode = true;
        ui->widget_voice_logo->hide();
        ui->label_callback_title->show();
        ui->label_tip->setText(tr("Inviting you to voice chat..."));
        ui->label_tip->show();
        ui->interruptButton->hide();
        m_showChatPage = false;
        ui->bottomWidget->setSubtitleChecked(false);
        updateMiddlePage();         // 强制显示 listening 页
        ui->bottomWidget->setCallbackMode(true);
    } else {
        m_callbackMode = false;
        ui->label_callback_title->hide();
        ui->widget_voice_logo->show();
        ui->bottomWidget->setCallbackMode(false);
        updateVoiceStateUi();       // 恢复原有 tip 文本
    }
}
```

### 3.6 handleAiReplyFinished 回拨触发

```cpp
if (!isVisible()) {
    if (m_callbackPending) {
        m_callbackPending = false;
        setState(Idle);
        beginCallbackAttempts();
        return;
    }
    // ... 原有逻辑
}
```

### 3.7 handleChatError 异常不回拨

回拨只用于“任务正常完成”的通知。若 Gateway 超时、异常失败或用户手动终止，本轮回拨等待应取消，不再弹出回拨窗口：

```cpp
if (m_callbackPending && !isVisible()) {
    m_callbackPending = false;
    return;
}
```

### 3.7.1 回拨异常处理与重试

回拨窗口出现后视为“振铃”。若 10 秒内用户未接听，则本次回拨失败并隐藏窗口；失败后间隔 1 分钟自动重试，最多 3 次（含首次）。第 3 次仍未接听时停止回拨，通过系统通知提示：

```
任务已完成，但回拨失败，请进入会话查看详细结果
```

用户在回拨界面主动点击挂断，视为用户已知晓本次完成通知，取消后续重试并隐藏回拨窗口。应用退出或关机后不持久化回拨状态，不再回拨。

关键状态/定时器：

- `m_callbackAttemptCount`：已发起回拨次数
- `m_callbackRingTimer`：单次 10 秒振铃计时
- `m_callbackRetryTimer`：失败后 1 分钟重试计时

### 3.8 onAnswerClicked 接听

```
用户点击接听按钮
  → setCallbackMode(false)      // 恢复所有 UI
  → setSubtitleChecked(true)    // 按下字幕按钮
  → m_showChatPage = true
  → updateMiddlePage()          // 显示会话页面
  → maybeStartRecognitionForCurrentState()  // 启动 ASR
```

### 3.9 onShowWindow 与回拨的兼容

当回拨等待、回拨响铃或回拨重试计时仍在进行时，说明上一轮会话尚未结束。此时用户从主助手点击语音聊天按钮，`ShowWindow()` 不应打断回拨流程，也不应刷新为普通首页：

```cpp
void MainWindow::onShowWindow()
{
    if (isCallbackFlowActive()) {
        return;
    }
    show();
    raise();
    activateWindow();
    startRecognitionForLauncherRequest();
}
```

`isCallbackFlowActive()` 覆盖 `m_callbackPending`、`m_callbackMode`、振铃计时器和重试计时器。回拨流程中外部唤起保持无动作，只有用户在回拨 UI 上主动接听或挂断，才改变上一轮会话状态。

### 3.10 回拨模式下的守卫

| 位置 | 守卫逻辑 |
|------|---------|
| `updateVoiceStateUi()` | `m_callbackMode` 时直接 return，不更新 UI |
| `canStartRecognition()` | 增加 `!m_callbackMode` 条件 |
| `onShowWindow()` / `onActivationMessage()` | `isCallbackFlowActive()` 时直接 return，不取消回拨、不刷新 UI、不重启 ASR |

## 四、文件改动详情

### 4.1 voice-assistant/src.qrc

新增接听图标资源：

```xml
<file>image/icon/call-start-symbolic.svg</file>
```

### 4.2 voice-assistant/minipanel.ui

新增 `pushButton_answer`（QPushButton），放在 `pushButton_cutoff` 之后：

```xml
<widget class="QPushButton" name="pushButton_answer">
  <property name="visible"><bool>false</bool></property>
  <property name="minimumSize"><size><width>48</width><height>48</height></size></property>
  <property name="maximumSize"><size><width>48</width><height>48</height></size></property>
  <property name="toolTip"><string>Answer</string></property>
  <property name="styleSheet">
    QPushButton { border-radius: 24px; background-color: #00B42A; border: 1px solid rgba(0,0,0,0); }
    QPushButton:hover { background-color: #00AB28; }
    QPushButton:pressed { background-color: #009022; }
  </property>
  <property name="icon">
    <iconset resource="src.qrc">:/image/icon/call-start-symbolic.svg</iconset>
  </property>
</widget>
```

### 4.3 voice-assistant/minipanel.h

新增：

```cpp
// 公有方法
void setCallbackMode(bool enabled);

// 信号
void answerClicked();

// 私有槽
void on_pushButton_answer_clicked();

// 私有成员
int m_normalLeftMargin = 24;
int m_normalRightMargin = 24;
int m_normalSpacing = -1;
```

### 4.4 voice-assistant/minipanel.cpp

构造函数中保存原始布局参数：

```cpp
m_normalLeftMargin = ui->horizontalLayout->contentsMargins().left();
m_normalRightMargin = ui->horizontalLayout->contentsMargins().right();
m_normalSpacing = ui->horizontalLayout->spacing();
```

新增 `on_pushButton_answer_clicked()`：

```cpp
void MiniPanel::on_pushButton_answer_clicked()
{
    emit answerClicked();
}
```

新增 `setCallbackMode(bool enabled)`：

```cpp
void MiniPanel::setCallbackMode(bool enabled)
{
    if (enabled) {
        ui->pushButton_screenshare->hide();
        ui->pushButton_subtitle->hide();
        ui->pushButton_mute->hide();
        ui->pushButton_answer->show();

        QMargins margins = ui->horizontalLayout->contentsMargins();
        margins.setLeft(88);
        margins.setRight(88);
        ui->horizontalLayout->setContentsMargins(margins);
        ui->horizontalLayout->setSpacing(16);
    } else {
        ui->pushButton_answer->hide();
        ui->pushButton_screenshare->show();
        ui->pushButton_subtitle->show();
        ui->pushButton_mute->show();

        QMargins margins = ui->horizontalLayout->contentsMargins();
        margins.setLeft(m_normalLeftMargin);
        margins.setRight(m_normalRightMargin);
        ui->horizontalLayout->setContentsMargins(margins);
        ui->horizontalLayout->setSpacing(m_normalSpacing);
    }
}
```

居中计算：2 个 48px 按钮 + 16px 间距 = 112px，窗口宽 288px，左右 margin 各 (288-112)/2 = 88px。

### 4.5 voice-assistant/mainwindow.ui

在 `horizontalLayout_voice` 中 `widget_voice_logo` 之后新增 `label_callback_title`（QLabel）：

```xml
<widget class="QLabel" name="label_callback_title">
  <property name="visible"><bool>false</bool></property>
  <property name="minimumSize"><size><width>73</width><height>27</height></size></property>
  <property name="maximumSize"><size><width>73</width><height>27</height></size></property>
  <property name="styleSheet">
    font-family: "Noto Sans CJK SC";
    font-size: 18px;
    font-weight: 500;
    color: rgba(0, 0, 0, 0.85);
  </property>
  <property name="text"><string notr="true">KylinBot</string></property>
  <property name="alignment"><set>Qt::AlignCenter</set></property>
</widget>
```

"KylinBot" 使用 `notr="true"` 标记，不参与翻译。

### 4.6 voice-assistant/mainwindow.h

新增：

```cpp
// 私有槽
void onAnswerClicked();

// 私有方法
void setCallbackMode(bool enabled);

// 私有成员
bool m_callbackPending = false;
bool m_callbackMode = false;
```

### 4.7 voice-assistant/mainwindow.cpp

改动点（共 7 处）：

| 改动点 | 改动内容 |
|--------|---------|
| `cutoffClicked` 槽 | 回拨模式→cancelCallbackFlow + hide；AI 工作中→m_callbackPending=true + hide()；其他→原有逻辑 |
| 新增 `answerClicked` 连接 | `connect(ui->bottomWidget, &MiniPanel::answerClicked, this, &MainWindow::onAnswerClicked)` |
| `updateVoiceStateUi()` | 入口增加 `if (m_callbackMode) { return; }` |
| `canStartRecognition()` | 增加 `&& !m_callbackMode` 条件 |
| `handleAiReplyFinished()` | 窗口不可见且 m_callbackPending 时，进入回拨重试流程 |
| `handleChatError()` | m_callbackPending 且窗口不可见时，清理等待状态，不触发回拨 |
| `onShowWindow()` | cancelCallbackFlow（用户主动回来，取消回拨等待或重试） |
| 新增 `setCallbackMode()` | 切换回拨/正常 UI 状态 |
| 新增 `onAnswerClicked()` | 接听→恢复 UI + 显示会话页 + 启动 ASR |

### 4.8 voice-assistant/translations/voice_assistant_zh_CN.ts

新增翻译：

| 英文原文 | 中文翻译 |
|---------|---------|
| `Inviting you to voice chat...` | `邀请你语音聊天...` |
| `Answer` | `接听` |

### 4.9 voice-assistant/translations/voice_assistant_zh_TW.ts

新增翻译：

| 英文原文 | 繁體翻譯 |
|---------|---------|
| `Inviting you to voice chat...` | `邀請你語音聊天...` |
| `Answer` | `接聽` |

## 五、关键流程图

### 5.1 AI 工作中挂断 → 回拨

```
[Processing/Answering] --挂断--> stopAudioForHide(), m_callbackPending=true, hide()
                                       |
                              AI 后台继续处理（Gateway 未取消）
                                       |
                          handleAiReplyFinished()
                                       |
                              !isVisible() && m_callbackPending?
                                ├─ YES → m_callbackPending=false
                                |         setState(Idle)
                                |         beginCallbackAttempts()
                                |         → [回拨界面/振铃10秒]
                                └─ NO  → 原有逻辑（朗读/Idle）
```

### 5.2 回拨界面交互

```
                    [回拨界面: KylinBot + "邀请你语音聊天..." + 挂断/接听]
                    /                          |                       \
              点挂断                       10秒未接听                点接听
                 |                            |                         |
        cancelCallbackFlow()           hide() + 1分钟后重试       setCallbackMode(false)
        hide()                         最多3次，均失败后通知      setSubtitleChecked(true)
        （不再继续拨打）                                            m_showChatPage=true
                                                        updateMiddlePage()
                                                        maybeStartRecognitionForCurrentState()
                                                        → [正常通话界面 + 会话页]
```

### 5.3 非AI工作时挂断 → 无回拨

```
[Idle/Listening/Reading] --挂断--> stopAudioForHide(), hide()
                                       |
                              下次点语音聊天按钮
                                       |
                              onShowWindow() → 正常恢复
                              （m_callbackPending=false，走原有路径）
```

### 5.4 AI 工作中挂断 → AI 完成前用户主动回来

```
[Processing/Answering] --挂断--> m_callbackPending=true, hide()
                                       |
                              用户从主助手点语音聊天按钮
                                       |
                              onShowWindow()
                                → m_callbackPending=false（取消回拨等待）
                                → show() + raise() + activateWindow()
                                       |
                              AI 回答完成后走正常流程（朗读回答等）
```

### 5.5 Gateway 错误时不回拨

```
[Processing/Answering] --挂断--> m_callbackPending=true, hide()
                                       |
                              handleChatError()
                                       |
                              m_callbackPending && !isVisible()?
                                ├─ YES → m_callbackPending=false
                                |         return（异常失败不触发回拨）
                                └─ NO  → 原有错误处理
```

### 5.6 回拨界面中再点挂断

```
[回拨界面] --挂断--> m_callbackMode==true → cancelCallbackFlow() + hide()
                              （视为用户已知晓完成通知，不再继续重试）
```

## 六、测试用例覆盖

| 测试场景 | 覆盖情况 |
|----------|----------|
| AI 回答中挂断 → AI 完成 → 回拨弹出 | handleAiReplyFinished 中 m_callbackPending 检测 |
| AI 回答中挂断 → Gateway 出错 → 不回拨 | handleChatError 中清理 m_callbackPending 并 return |
| 非AI 工作时挂断 → 无回拨 | cutoffClicked 中仅 Processing/Answering 设 m_callbackPending |
| 回拨界面点接听 → 恢复正常+会话页 | onAnswerClicked 实现 |
| 回拨界面点挂断 → 停止重试并隐藏 | cutoffClicked 中 m_callbackMode 分支 |
| 回拨 10 秒未接听 → 失败重试 | m_callbackRingTimer 超时后隐藏并启动 m_callbackRetryTimer |
| 3 次均失败 → 系统通知 | finishCallbackFailure 发送系统通知 |
| 回拨界面 UI 验证 | setCallbackMode 切换 widget_voice_logo/label_callback_title/label_tip/MiniPanel |
| 回拨流程中主助手点语音聊天按钮 | onShowWindow 直接忽略，保持回拨 UI 或重试状态不变 |
| 回拨界面 KylinBot 文本不翻译 | `notr="true"` 标记 |
| 回拨界面 tip 翻译 | zh_CN: "邀请你语音聊天...", zh_TW: "邀請你語音聊天..." |
| 接听按钮三态样式 | #00B42A / #00AB28 / #009022 |

## 七、风险与缓解

| 风险 | 缓解措施 |
|------|----------|
| AI 工作中挂断后 Gateway 连接断开 | Gateway 基于 WebSocket，连接与窗口无关；hide() 不影响网络通信 |
| handleAiReplyFinished 中 isRoundCompleted() 误判 | m_callbackPending 路径在 isRoundCompleted() 检查之后，此时 state 仍为 Processing/Answering，不会被误判 |
| 异常失败误触发回拨 | m_callbackPending + 隐藏窗口分支只清理状态，不弹回拨 |
| 回拨模式下 ASR 误启动 | canStartRecognition() 增加 `!m_callbackMode` 条件 |
| 回拨模式下 updateVoiceStateUi 覆盖 tip 文本 | updateVoiceStateUi() 入口 guard：m_callbackMode 时直接 return |
| MiniPanel 布局切换时 margin/spacing 不一致 | 构造时保存原始值（m_normalLeftMargin 等），退出回拨模式时恢复 |
| onShowWindow 与回拨竞态 | onShowWindow 在 isCallbackFlowActive() 时直接忽略，避免刷新回拨 UI 或误取消上一轮会话 |
| 关闭按钮退出不触发回拨 | closeButton 走 shutdownAndQuit()，直接 quit 不设 m_callbackPending |
| 回拨重试跨进程/重启重复触发 | 回拨状态只保存在内存，应用退出后不持久化重试 |
