# H5 流式活动状态行实现计划

## 目标

将当前 H5 的"正在思考"占位状态，升级为一个**动态活动状态行**。

它在整个 AI 流式回复过程中持续工作：

- 没有正文时作为主占位显示
- 有正文时作为末尾弱提示显示
- 收到工具/Agent/重试等事件时动态替换文案
- 收到新正文时自动隐藏
- 流式结束时清空

核心原则：

```text
正文优先
状态弱化
同一时刻只显示一条
不展示工具细节，只告诉用户"在做什么"
```

---

## 改动文件

共 4 个文件：

```text
src/components/h5/use-h5-chat-stream.ts
src/components/h5/h5-client-app.tsx
src/components/h5/h5-streaming-bubble.tsx
src/components/h5/h5-client-app.css
```

后端 engine 层不需要改动，所需 SSE 事件已通过 `createSseCallbacks` 下发。

---

## 文件 1：`use-h5-chat-stream.ts`

这是改动最核心的文件，负责处理新 SSE 事件并维护 `streamingStatus` state。

### 新增类型

在文件顶部或 `src/lib/h5/types.ts` 中添加：

```ts
export type H5StreamingStatusTone = 'default' | 'warning' | 'muted'
export type H5StreamingStatusState = 'active' | 'cancelled'

export interface H5StreamingStatus {
  text: string
  tone?: H5StreamingStatusTone
  state?: H5StreamingStatusState
}
```

### 工具名到状态文案的映射函数

在文件内添加纯函数：

```ts
function mapToolNameToStatus(toolName: string): string {
  const name = toolName.toLowerCase()
  if (name === 'read') return '正在读取相关文件...'
  if (name === 'grep' || name === 'glob' || name === 'ls') return '正在查找相关内容...'
  if (name === 'websearch' || name === 'webfetch' || name.includes('web')) return '正在搜索网页...'
  if (name === 'write' || name === 'edit' || name === 'multiedit') return '正在整理修改内容...'
  if (name === 'bash') return '正在执行必要操作...'
  if (name === 'agent' || name === 'task') return '正在处理子任务...'
  if (name === 'todowrite') return '正在更新任务进度...'
  return '正在处理...'
}
```

### 新增 state

```ts
const [streamingStatus, setStreamingStatus] = useState<H5StreamingStatus | null>(null)
```

### 修改 `stream()` 函数

#### 初始化时设置初始状态

在清空其他 state 的同一位置追加：

```ts
setStreamingStatus({ text: '正在思考...' })
```

#### 在 SSE 事件解析 switch 块中新增 case

现有忽略的事件改为消费：

```ts
// 当前工具调用开始，根据工具名显示正在做什么
case 'tool_use_start': {
  const toolName = (data.name as string) || ''
  setStreamingStatus({ text: mapToolNameToStatus(toolName) })
  break
}

// 工具结果返回，短暂提示整理中
case 'tool_result':
case 'tool_summary': {
  setStreamingStatus({ text: '正在整理结果...' })
  break
}

// Todo 更新，显示当前处理步骤
case 'todo_update': {
  const todos = data.todos as Array<{ description: string; status: string }>
  const current = Array.isArray(todos)
    ? todos.find(t => t.status === 'in_progress')
    : undefined
  setStreamingStatus({
    text: current ? `正在处理：${current.description}` : '正在更新任务进度...',
  })
  break
}

// 子 Agent 启动
case 'agent_start': {
  const description = data.description as string | undefined
  setStreamingStatus({
    text: description ? `正在处理子任务：${description}` : '正在处理子任务...',
  })
  break
}

// 子 Agent 完成，继续整理
case 'agent_done': {
  setStreamingStatus({ text: '子任务已完成，正在整理结果...' })
  break
}

// 子 Agent 失败，继续尝试
case 'agent_error': {
  setStreamingStatus({ text: '子任务处理失败，正在继续...' })
  break
}

// API 重试，提示用户不是卡住了
case 'api_retry': {
  const attempt = data.attempt as number | undefined
  const text = attempt && attempt > 1
    ? `网络波动，正在重试（第 ${attempt} 次）`
    : '网络波动，正在重试...'
  setStreamingStatus({ text, tone: 'warning' })
  break
}

// 用户停止或 session 取消
case 'cancelled': {
  setStreamingStatus({ text: '已停止生成', tone: 'muted', state: 'cancelled' })
  break
}
```

#### 修改 `text_delta` case

收到正文时隐藏状态，让正文优先：

```ts
case 'text_delta': {
  assistantText += data.text as string
  setStreamingText(assistantText)
  setStreamingStatus(null)   // 收到正文，隐藏状态行
  break
}
```

#### 修改 `done` case

在现有清空逻辑中追加清空 status：

```ts
case 'done': {
  await onDoneRef.current()
  setStreamingText('')
  setStreamingError(null)
  setStreamingAttachments([])
  setStreamingStatus(null)  // 流式结束，清空状态
  streamFinished = true
  break
}
```

### 修改 `stop()` 函数

用户主动停止时设置最终状态：

```ts
// 原有逻辑之后追加
setStreamingStatus({ text: '已停止生成', tone: 'muted', state: 'cancelled' })
```

注意：此状态是短暂的，`finally` 里 `setStreaming(false)` 后组件可以选择隐藏整个流式气泡。
可以在 `finally` 里也 `setStreamingStatus(null)` 将其清空，取决于"停止后气泡是否马上消失"的产品决策。

### 修改返回值

```ts
return {
  streaming,
  streamingText,
  streamingError,
  streamingAttachments,
  streamingStatus,   // 新增
  stream,
  stop,
}
```

---

## 文件 2：`h5-client-app.tsx`

只需把 `streamingStatus` 从 hook 解构出来，并传给 `H5StreamingBubble`。

### 解构 hook 返回值

```ts
const {
  streaming,
  streamingText,
  streamingError,
  streamingAttachments,
  streamingStatus,   // 新增
  stream,
  stop,
} = useH5ChatStream({ onDone, onUnauthorized })
```

### 传给 H5StreamingBubble

找到原有：

```tsx
<H5StreamingBubble
  streamingText={streamingText}
  streamingError={streamingError}
  streamingAttachments={streamingAttachments}
/>
```

改为：

```tsx
<H5StreamingBubble
  streamingText={streamingText}
  streamingError={streamingError}
  streamingAttachments={streamingAttachments}
  streamingStatus={streamingStatus}
/>
```

---

## 文件 3：`h5-streaming-bubble.tsx`

改动两处：

1. 新增 `streamingStatus` prop
2. 渲染逻辑中增加状态行

### 新增 props 类型

```ts
import type { H5StreamingStatus } from '@/lib/h5/types'

interface H5StreamingBubbleProps {
  streamingText: string
  streamingError: string | null
  streamingAttachments: StreamingAttachment[]
  streamingStatus?: H5StreamingStatus | null  // 新增
}
```

### 修改空状态分支

原有：

```tsx
// 空状态（第 3 种情况）
<div className="h5-tool-bubble">
  <span className="h5-typing-dots">
    <span className="h5-typing-dot" />
    <span className="h5-typing-dot" />
    <span className="h5-typing-dot" />
  </span>
  正在思考
</div>
```

改为：

```tsx
// 无正文时，用 streamingStatus 作为主显示内容（向下兼容，默认"正在思考"）
<div className="h5-tool-bubble">
  {streamingStatus ? (
    <span
      className="h5-stream-status"
      data-tone={streamingStatus.tone ?? 'default'}
      data-state={streamingStatus.state ?? 'active'}
    >
      <span className="h5-stream-status-dot" />
      <span>{streamingStatus.text}</span>
    </span>
  ) : (
    // 无 status 也保留原有 typing dots 作为兜底
    <>
      <span className="h5-typing-dots">
        <span className="h5-typing-dot" />
        <span className="h5-typing-dot" />
        <span className="h5-typing-dot" />
      </span>
      正在思考
    </>
  )}
</div>
```

### 在有正文的分支末尾追加状态行

原有（有文本或附件时的渲染区块）在最后追加：

```tsx
{streamingStatus && (
  <div
    className="h5-stream-status"
    data-tone={streamingStatus.tone ?? 'default'}
    data-state={streamingStatus.state ?? 'active'}
  >
    <span className="h5-stream-status-dot" />
    <span>{streamingStatus.text}</span>
  </div>
)}
```

最终渲染结构示意：

```tsx
<div className="h5-msg assistant">
  <div className="h5-msg-content">
    {/* 1. error 态（已有） */}
    {/* 2. 有正文时的主内容（已有） */}
    {streamingStatus && (
      <div
        className="h5-stream-status"
        data-tone={...}
        data-state={...}
      >
        <span className="h5-stream-status-dot" />
        <span>{streamingStatus.text}</span>
      </div>
    )}
    {/* 3. 空状态时替换原有 typing dots */}
  </div>
</div>
```

---

## 文件 4：`h5-client-app.css`

H5 唯一的 CSS 文件，在文件末尾追加以下样式：

```css
/* ─── H5 Streaming Activity Status Line ─── */

.h5-stream-status {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  max-width: 100%;
  margin-top: 10px;
  color: var(--h5-text-muted);
  font-size: var(--h5-text-sm);  /* 12px */
  line-height: 18px;
  opacity: 0.76;
}

/* 无正文时作为主内容，稍大间距 */
.h5-tool-bubble .h5-stream-status {
  margin-top: 0;
  font-size: var(--h5-text-base);  /* 14px */
  opacity: 0.72;
}

/* 警告色调：api_retry */
.h5-stream-status[data-tone='warning'] {
  color: var(--h5-warning);
  opacity: 0.85;
}

/* 弱化色调：cancelled / 停止 */
.h5-stream-status[data-tone='muted'] {
  opacity: 0.55;
}

/* 状态点 */
.h5-stream-status-dot {
  flex: 0 0 auto;
  width: 5px;
  height: 5px;
  border-radius: 999px;
  background: currentColor;
  opacity: 0.6;
  animation: h5-status-pulse 1.4s ease-in-out infinite;
}

/* cancelled 时停止动画 */
.h5-stream-status[data-state='cancelled'] .h5-stream-status-dot {
  animation: none;
  opacity: 0.4;
}

@keyframes h5-status-pulse {
  0%, 100% {
    opacity: 0.3;
    transform: scale(0.85);
  }
  50% {
    opacity: 0.85;
    transform: scale(1);
  }
}
```

---

## 状态行完整生命周期

```text
用户发送消息
  ↓
stream() 初始化
  setStreamingStatus({ text: '正在思考...' })

  ↓ 收到 text_delta
  setStreamingText(text)
  setStreamingStatus(null)          ← 正文开始，隐藏状态

  ↓ 收到 tool_use_start
  setStreamingStatus({ text: '正在查找相关内容...' })

  ↓ 收到 tool_result
  setStreamingStatus({ text: '正在整理结果...' })

  ↓ 收到 text_delta（正文继续）
  setStreamingStatus(null)          ← 有正文，再次隐藏

  ↓ 收到 api_retry
  setStreamingStatus({ text: '网络波动，正在重试...', tone: 'warning' })

  ↓ 收到 text_delta
  setStreamingStatus(null)

  ↓ 收到 done
  setStreamingText('')
  setStreamingStatus(null)
  流式气泡替换为持久化消息

  ── 或 ──

  ↓ 用户点停止
  setStreamingStatus({ text: '已停止生成', tone: 'muted', state: 'cancelled' })
  setStreaming(false)
```

---

## 视觉效果示意

### 没有正文时

```
┌─────────────────────────────┐
│ AI                           │
│ · 正在查找相关内容...         │
└─────────────────────────────┘
```

### 有正文，中间调用工具

```
┌─────────────────────────────┐
│ AI                           │
│ 我先看一下项目结构。           │
│                              │
│ · 正在读取相关文件...          │
└─────────────────────────────┘
```

### 正文继续后工具状态隐藏

```
┌─────────────────────────────┐
│ AI                           │
│ 我先看一下项目结构。           │
│                              │
│ 登录入口在 app/login。        │
└─────────────────────────────┘
```

### API 重试

```
┌─────────────────────────────┐
│ AI                           │
│ 我先看一下项目结构。           │
│                              │
│ · 网络波动，正在重试（第2次）   │  ← 略带暖色
└─────────────────────────────┘
```

### 已停止

```
┌─────────────────────────────┐
│ AI                           │
│ 我先看一下项目结构。           │
│                              │
│ · 已停止生成                  │  ← 灰弱色，无动画
└─────────────────────────────┘
```

---

## 状态文案映射表

| 触发事件 | 状态文案 | tone |
|---|---|---|
| stream 开始 | 正在思考... | default |
| `tool_use_start: Read` | 正在读取相关文件... | default |
| `tool_use_start: Grep/Glob/LS` | 正在查找相关内容... | default |
| `tool_use_start: WebSearch/WebFetch` | 正在搜索网页... | default |
| `tool_use_start: Write/Edit/MultiEdit` | 正在整理修改内容... | default |
| `tool_use_start: Bash` | 正在执行必要操作... | default |
| `tool_use_start: Agent/Task` | 正在处理子任务... | default |
| `tool_use_start: TodoWrite` | 正在更新任务进度... | default |
| `tool_use_start: 其他` | 正在处理... | default |
| `tool_result / tool_summary` | 正在整理结果... | default |
| `todo_update`（有 in_progress） | 正在处理：{description} | default |
| `todo_update`（无 in_progress） | 正在更新任务进度... | default |
| `agent_start`（有 description） | 正在处理子任务：{description} | default |
| `agent_start`（无 description） | 正在处理子任务... | default |
| `agent_done` | 子任务已完成，正在整理结果... | default |
| `agent_error` | 子任务处理失败，正在继续... | default |
| `api_retry`（attempt > 1） | 网络波动，正在重试（第N次） | warning |
| `api_retry`（attempt ≤ 1） | 网络波动，正在重试... | warning |
| `cancelled` | 已停止生成 | muted |
| `text_delta` | null（隐藏状态） | — |
| `done` | null（清空状态） | — |
| `error` | 走现有 error 态 | — |

---

## 不需要改动的内容

| 类别 | 说明 |
|---|---|
| 后端 engine 层 | `createSseCallbacks` 已透传所有新事件 |
| H5 route | `src/app/api/h5/chat/route.ts` 不需要改动 |
| 消息持久化结构 | 状态行只存在于流式阶段，不落库 |
| IM 层 | 本次只做 H5 |
| 桌面端 | 本次只做 H5 |
| `thinking_delta` 相关 | H5 不展示思考内容，维持现状 |
| `question_request` 卡片 | 属于下一阶段，本次不做 |
| `plan_review_request` 卡片 | 属于下一阶段，本次不做 |
| `web_search_result` 来源展示 | 属于下一阶段，本次不做 |

---

## 实施顺序建议

```text
1. 在 src/lib/h5/types.ts 新增 H5StreamingStatus 类型
2. 修改 use-h5-chat-stream.ts：新增 state + mapToolNameToStatus + 各 case
3. 修改 h5-streaming-bubble.tsx：新增 props + 渲染状态行
4. 修改 h5-client-app.tsx：解构 streamingStatus 并传入 bubble
5. 在 h5-client-app.css 末尾追加 status 样式
6. 本地测试各场景
7. 测试通过后删除 sse-adapter.ts 里的 [semantic] console.log
```
