Skill: dyz2102/xiabb (version 1.3.2)

--- name: 虾BB description: 免费 macOS 语音转文字，专为 Vibe Coding 设计。按住 Globe 键说话，文字自动出现在光标位置。支持智能模式：翻译、Prompt 优化、邮件生成。Powered by Google Gemini。 version: 1.3.2 license: MIT-0 author: dyz2102 homepage: https://xiabb.lol repository: https://github.com/dyz2102/xiabb tags: voice, speech-to-text, macos, gemini, vibe-coding, translate, prompt --- # 虾BB 🦞 **按住 Globe 键，说话，文字出现。** 专为 Vibe Coding 设计的免费开源 macOS 语音转文字。纯 Swift 原生，341KB，~38MB 内存。 ## 功能 - 🌐 **Globe 键一键录音** — 按住说话，松开自动转写+粘贴 - 🔴 **实时流式预览** — 说话时文字实时出现在 HUD 上 - 🌏 **中英完美混输** — "帮我 schedule a meeting" → 完美识别 - 🧠 **LLM 而非 ASR** — Google Gemini 理解语义，标点完美 ## 🧠 Smart Modes 智能模式 录音时按快捷键，AI 自动处理： | 快捷键 | 模式 | 效果 | |--------|------|------| | `Globe + `` | 🔄 翻译 | 说中文出英文，说英文出中文，自动识别 | | `Globe + 1` | ⚡ 提示词 | 口述想法，自动生成专业 AI 提示词 | | `Globe + 2` | 📧 邮件 | 口述内容，自动生成格式规范的邮件 | | `Globe + Esc` | ⛔ 取消 | 丢弃录音 | 再按同一键可切换回普通听写模式。 ## 竞品对比 | | 虾BB | Typeless | Wispr Flow | SuperWhisper | |---|---|---|---|---| | 价格 | **免费** | $12/月 | $15/月 | $8.49/月 | | 内存 | **~38MB** | **~1GB** | ~200MB | ~500MB+ | | 体积 | 341KB | ~50MB | ~50MB | ~100MB | | 开源 | ✅ | ❌ | ❌ | ❌ | ## 安装 ```bash # 下载 DMG open https://github.com/dyz2102/xiabb/releases/latest # 或从源码编译 git clone https://github.com/dyz2102/xiabb.git && cd xiabb && bash install.sh ``` 需要免费的 Gemini API Key：[aistudio.google.com/apikey](https://aistudio.google.com/apikey) macOS 14+ · Apple Silicon · MIT License

path: CHANGELOG.md
content: # Changelog

## v1.3.0 (2026-04-02)

### 新功能 / New Features
- **Smart Modes 智能模式** — 录音时按快捷键切换 AI 处理模式
  - `Globe + `` — 翻译模式：说中文出英文，说英文出中文
  - `Globe + 1` — Prompt 优化：口述想法，自动生成专业 AI 提示词
  - `Globe + 2` — 邮件生成：口述内容，自动生成格式规范的邮件
  - `Globe + Esc` — 取消录音
  - 再按同一键可切换回普通听写模式
- **HUD 模式指示器** — 录音时右侧显示当前模式（emoji + 文字）
- **更新检查** — 启动时自动检查新版本，菜单栏可手动检查，有更新一键跳转下载
- **反馈入口** — 菜单栏"反馈意见"按钮，直接发邮件
- **API 错误友好提示** — 断网、超时、API Key 失效等错误显示中文提示，不再显示原始错误

### 改进 / Improvements
- Prompt 模式：忠实保留用户描述的主体信息（性别、对象、场景），不擅自添加或篡改细节
- Prompt 模式：强制保持输入语言，中文输入不会变成英文输出
- 所有模式支持自定义词典（Claude/OpenClaw 等专有名词不会被错误识别）
- HUD 文字过长时自动为模式标签留出空间，不会被文字覆盖

### 修复 / Bug Fixes
- 修复 HUD 模式徽章被窗口边界裁剪不可见的问题
- 修复模式切换按键（`, 1, 2）会输入到文本框的问题

## v1.2.0 (2026-03-29)

- WKWebView 引导流程
- 自定义词典
- Crash 修复
- Gruvbox 主题

## v1.1.3 (2026-03-24)

- 修复麦克风权限问题

## v1.1.0 (2026-03-24)

- HUD 优化 + 长录音修复 + 官网上线

## v1.0.0 (2026-03-19)

- 初始发布


path: README.md
content: <div align="center">

# 🦞 虾BB

### 按住 Globe 键，说话，文字出现。

**专为 Vibe Coding 设计的免费开源 macOS 语音转文字**

*Free & open-source macOS voice-to-text, built for Vibe Coding.*

[![Swift](https://img.shields.io/badge/Swift-6.2-orange?logo=swift&logoColor=white)](https://swift.org)
[![macOS](https://img.shields.io/badge/macOS-14%2B-black?logo=apple&logoColor=white)](https://www.apple.com/macos/)
[![License](https://img.shields.io/badge/License-MIT-green)](LICENSE)
[![Binary Size](https://img.shields.io/badge/体积-280KB-blue)]()
[![Gemini](https://img.shields.io/badge/Powered%20by-Gemini%20API-4285F4?logo=google&logoColor=white)](https://ai.google.dev/)

<br>

<img src="xiabb-demo.gif" width="720" alt="虾BB 演示">

<br>

*中英文混着说，它都能搞定。*

</div>

---

## 为什么做虾BB？

如果你跟我一样每天 Vibe Coding 十个小时，你就知道一款免费、准确、开源的语音转文字有多重要。

Vibe Coding 注定你说话时会夹大量英文单词 —— "帮我把这个 component 的 onClick handler 改成 async 的" —— 标点符号和断句对 AI 理解你的需求至关重要，而许多大模型要么语言能力单一，要么在封装起来的产品里需要收费。

市面上很多语音工具想做 AI 助手、会议纪要、多人协作。**虾BB 不做这些。** 它只做一件事：你说话，文字出现在光标位置。纯粹、快速、免费。

> **虾BB = 瞎BB** —— 最好的想法，说出来的时候都像在瞎说。🦞

希望你能喜欢。欢迎提交 Pull Request 或 Issues，一起共创属于 AI 时代 Vibe Coder 的完美语音转文字工具。

---

## 竞品对比

| | **虾BB (Gemini)** | SuperWhisper | Typeless | Wispr Flow | MacWhisper |
|---|---|---|---|---|---|
| **价格** | **免费** | $8.49/月 或 $249 买断 | 免费 4000 词/周，$12/月 | $15/月 | $29 买断 |
| **免费额度** | **250 次/天** | 无 | 4000 词/周 (~27 分钟) | 无 | 有限 |
| **中英混搭** | **很好** | 一般 | 一般 | 好 | 一般 |
| **标点符号** | **完美** | 一般 | 好（AI润色） | 好 | 没有 |
| **实时预览** | **有（边说边看）** | 无 | 无 | 有 | 无 |
| **识别引擎** | **LLM（理解语义）** | Whisper ASR | 云端 ASR | 云端 ASR + LLM | Whisper ASR |
| **体积** | **280KB** | ~100MB | ~50MB | ~50MB | ~50MB |
| **内存占用** | **~38MB** | ~500MB+ | **~1GB** | ~200MB | ~4GB |
| **开源** | **是** | 否 | 否 | 否 | 否 |
| **隐私** | 音频发 Google | 本地 | 云端 | 云端（含截屏） | 本地 |
| **单次最长** | **9.5 小时** | 依赖模型 | 未知 | 未知 | 容易出错 |

**核心差异**：虾BB 用的不是传统 ASR（语音识别），而是 **Google Gemini LLM（大语言模型）**。Whisper 只做声音→文字的机械转换，不理解你在说什么。Gemini 是先听懂，再输出 —— 标点完美、错别字少、中英切换自然。更重要的是，Gemini 免费额度每天 250 次，比所有竞品都慷慨。

---

## 功能

<table>
<tr>
<td width="50%">

**🌐 Globe 键一键录音**
按住录音，松开转写+自动粘贴。一个键搞定一切。

**🔴 实时流式预览**
边说边看文字出现在 HUD 上。Gemini Live API 实时流式传输 —— 这是竞品很少有的体验。

**🌏 真正的中英混输**
"帮我 schedule a meeting" → 完美识别。Vibe Coding 必备。

</td>
<td width="50%">

**📋 自动粘贴**
文字直接出现在光标位置，任何 app 都能用。

**⚡ 280KB 极致轻量**
纯 Swift 原生。没有 Electron，没有 Python，没有 Node。

**🎙 超长录音支持**
单次最长 9.5 小时。MacWhisper 长音频经常出错，虾BB 不会。

**🆓 完全免费**
Google Gemini 免费额度，每天 250 次，对个人用户完全够。

</td>
</tr>
</table>

### 🧠 Smart Modes 智能模式

录音时按快捷键，AI 自动处理你的语音：

| 快捷键 | 模式 | 效果 |
|--------|------|------|
| `Globe + `` | 翻译 | 说中文出英文，说英文出中文，自动识别 |
| `Globe + 1` | 提示词 | 口述想法，自动生成专业 AI 提示词 |
| `Globe + 2` | 邮件 | 口述内容，自动生成格式规范的邮件 |
| `Globe + Esc` | 取消 | 丢弃录音 |

再按同一键可切换回普通听写模式。

**彩蛋：** 龙虾麦克风 HUD 悬浮窗 + 声波动画 + 识别成功弹出 🦞BB!

---

## 安装

**方式一：直接下载（推荐）**

去 [Releases](https://github.com/dyz2102/xiabb/releases) 下载 `XiaBB-v1.0.0-macOS-arm64.zip`，解压，拖到 Applications，双击打开。

已通过 Apple 公证（Notarized），不会弹 Gatekeeper 警告。

**方式二：从源码编译**

```bash
git clone https://github.com/dyz2102/xiabb.git
cd xiabb && bash install.sh
```

<details>
<summary><b>手动编译</b></summary>

```bash
xcode-select --install  # 需要 Xcode 命令行工具
cd xiabb/native && bash build.sh
open /Applications/XiaBB.app
```

要求 macOS 14+，Apple Silicon。

</details>

---

## 设置（3 步）

| 步骤 | 操作 |
|------|------|
| **1. 获取 API Key** | 免费申请：[aistudio.google.com/apikey](https://aistudio.google.com/apikey) → 保存到 `.api-key` 文件或设置环境变量 `GEMINI_API_KEY` |
| **2. 辅助功能权限** | 系统设置 → 隐私与安全性 → 辅助功能 → 添加 **Terminal.app** |
| **3. 麦克风权限** | 首次录音时系统会弹窗，点允许 |

---

## 使用方法

| 操作 | 效果 |
|------|------|
| **按住 🌐 Globe 键** | 开始录音，HUD 显示实时预览 |
| **松开 🌐 Globe 键** | 转写完成 → 复制 → 粘贴到光标位置 |
| **Globe + `** | 🔄 翻译模式（自动识别中↔英） |
| **Globe + 1** | ⚡ 生成 AI 提示词 |
| **Globe + 2** | 📧 生成邮件 |
| **Globe + Esc** | 取消录音 |
| **点击 HUD** | 复制上次结果 |
| **拖动 HUD** | 移动到任意位置 |

说话就行，不用管标点。中英文随便混着说。

---

## 技术架构

```
按住 🌐 ─── AVAudioEngine (16kHz) ──┬──> Gemini Live WebSocket
                                     │   (流式预览 → HUD 实时显示)
                                     │
松开 🌐 ─── WAV 编码 ──────────────>└──> Gemini REST API
                                          (最终准确转写)
                                               │
                                      📋 剪贴板 + ⌘V 粘贴
```

双引擎架构：说话时用 Live API 做实时预览（低延迟），松手后用 REST API 做最终转写（高准确率）。

<details>
<summary><b>配置选项</b></summary>

`.config.json`：

| 键 | 默认值 | 说明 |
|-----|---------|------|
| `lang` | `"zh"` | 界面语言（`"en"` 或 `"zh"`） |
| `min_duration` | `2.0` | 最短录音秒数（更短的会被忽略） |
| `hud_x`/`hud_y` | 屏幕居中 | HUD 位置（或直接拖动） |
| `theme` | `"lobster"` | 皮肤主题 |

环境变量：`GEMINI_API_KEY`，`XIABB_MODEL`（默认 `gemini-2.5-flash`）

</details>

<details>
<summary><b>常见问题</b></summary>

**Globe 键没反应？** → 在系统设置 → 辅助功能里添加 Terminal.app 权限。

**日志显示 "Too short"？** → 按住 Globe 键久一点，或在配置里降低 `min_duration`。

**出现繁体字？** → 自动转换简体。如果还有问题请开 Issue。

**查看日志？** → `tail -f ~/Library/Logs/XiaBB.log`

**卸载？** → `bash uninstall.sh`

</details>

---

## English

XiaBB (虾BB) is a free, open-source macOS voice-to-text tool powered by Google Gemini, built for Vibe Coding.

Hold the Globe key to speak, release to transcribe. Works seamlessly with mixed Chinese and English. Pure Swift, 280KB binary, zero dependencies.

- **Download:** [Releases](https://github.com/dyz2102/xiabb/releases)
- **API Key:** Free from [Google AI Studio](https://aistudio.google.com/apikey)
- **Requires:** macOS 14+, Apple Silicon

---

<div align="center">

**用 🦞 做的 by [dyz2102](https://github.com/dyz2102)**

MIT License · [xiabb.lol](https://xiabb.lol)

</div>


path: content/kimi-challenge-prompts.md
content: # 30分钟 Kimi Code 挑战 — Prompt 模板

> 目标：用 Kimi Code 从零做一个能用的终端版语音转文字工具
> 拍摄时按顺序复制粘贴这些 prompt，每一步都会产生可展示的进度

---

## Prompt 1：基础框架（预计5分钟）

```
用纯 Swift 写一个 macOS 命令行语音转文字工具，单文件 main.swift。

功能：
1. 运行后提示 "按 Enter 开始录音"
2. 按 Enter 开始录音（AVAudioEngine，16kHz mono PCM）
3. 再按 Enter 停止录音
4. 把录音保存为 WAV（加 WAV header）
5. 调用 Google Gemini REST API 转录
6. 输出转录文字到终端

Gemini API 调用方式：
- POST https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent
- Header: x-goog-[REDACTED_SECRET] GEMINI_API_KEY 读取)
- Body: {"contents":[{"parts":[{"text":"Transcribe this audio exactly as spoken, with proper punctuation. Chinese must be Simplified. Preserve original language per word. Output ONLY the transcribed text."},{"inline_data":{"mime_type":"audio/wav","data":"<base64>"}}]}],"generationConfig":{"temperature":0.0,"maxOutputTokens":4096}}

编译方式：swiftc main.swift -o voicetest -framework AVFoundation -framework CoreAudio
```

**拍摄点**: Kimi 开始输出代码，这里可以 4x 加速

---

## Prompt 2：修 Bug（预计3-5分钟）

编译大概率报错，直接贴错误：

```
编译报错了，帮我修：

<粘贴 swiftc 报错信息>
```

重复直到编译通过。

**拍摄点**: 来回修 bug 的过程展示 Vibe Coding 的真实感，不需要加速太多

---

## Prompt 3：第一次测试（预计2分钟）

编译通过后运行测试：

```bash
export GEMINI_API_KEY="你的key"
./voicetest
```

按 Enter 录音，说一句："这是用 Kimi Code 做出来的语音转文字工具"

**拍摄点**: 第一次成功出结果，这是高潮时刻，正常速度播放

---

## Prompt 4：加 Globe 键支持（预计8分钟）

```
现在改成用 Globe 键（fn 键）控制录音：

1. 用 CGEventTap 监听 flagsChanged 事件
2. 检测 fn flag (0x800000)
3. fn 按下 → 开始录音，终端显示 "🔴 录音中..."
4. fn 松开 → 停止录音，发送到 Gemini，显示结果
5. 需要 Accessibility 权限，用 AXIsProcessTrustedWithOptions 提示用户
6. 加一个 5 秒 watchdog timer，如果 event tap 被系统 disable 了就重新启用

编译命令加上：-framework ApplicationServices -framework CoreGraphics

注意 event tap callback 必须极快返回，所有逻辑放到 DispatchQueue.global() 异步执行。
```

**拍摄点**: 这是最关键的一步，Globe 键是虾BB 的核心。这里可以 2x 加速

---

## Prompt 5：修 Globe 键相关 Bug（预计5分钟）

可能遇到的问题：
- Event tap 创建失败 → 需要 Accessibility 权限
- fn 键检测不到 → flag 值不对
- 录音在 callback 线程崩溃 → 要 dispatch 到主线程

```
<粘贴报错或问题描述>
```

**拍摄点**: 如果 Accessibility 权限弹窗出来，这是很好的画面

---

## Prompt 6：加自动粘贴（预计3分钟）

```
转录完成后，自动把文字粘贴到光标位置：

1. 用 NSPasteboard 把文字写入剪贴板
2. 用 CGEvent 模拟 Cmd+V 按键
3. 粘贴完成后终端显示 "✅ 已粘贴: <文字>"

代码：
func copyAndPaste(_ text: String) {
    let pb = NSPasteboard.general
    pb.clearContents()
    pb.setString(text, forType: .string)
    // Simulate Cmd+V
    let src = CGEventSource(stateID: .hidSystemState)
    let keyDown = CGEvent(keyboardEventSource: src, virtualKey: 0x09, keyDown: true) // V key
    let keyUp = CGEvent(keyboardEventSource: src, virtualKey: 0x09, keyDown: false)
    keyDown?.flags = .maskCommand
    keyUp?.flags = .maskCommand
    keyDown?.post(tap: .cghidEventTap)
    keyUp?.post(tap: .cghidEventTap)
}
```

**拍摄点**: 测试粘贴——打开一个文本编辑器，按 Globe 说话，松开后文字出现在编辑器里。这是最有视觉冲击力的时刻。

---

## Prompt 7（可选加分项）：加实时预览

如果时间还够（<25分钟），可以加：

```
加一个简单的实时预览功能：录音时终端每秒显示 "🔴 录音中... Xs"，显示已录音时长。
```

---

## 最终测试脚本

录完最终版本后，说这几句话测试，展示中英混搭能力：

1. "帮我查一下 OpenClaw 最新的 skills 列表"
2. "用 Claude Code 写一个 API endpoint，要支持 async 和 error handling"
3. "这个 bug 在 production 环境才能复现，需要加 logging"
4. "帮我写一封邮件给 investor，说 Q1 report 已经发了"

---

## 计时里程碑（贴在屏幕旁边参考）

| 时间 | 里程碑 | 状态 |
|------|--------|------|
| 0:00 | 开始，打开 Kimi Code | 🟢 |
| 5:00 | Prompt 1 代码生成完 | 🟢 |
| 10:00 | 编译通过 | 🟢 |
| 12:00 | 第一次录音+转录成功 | 🟡 关键时刻 |
| 20:00 | Globe 键工作 | 🟡 关键时刻 |
| 25:00 | 自动粘贴工作 | 🟡 关键时刻 |
| 30:00 | 最终测试完成 | 🔴 计时结束 |

## 备用方案

如果30分钟内搞不定 Globe 键（CGEventTap 权限问题是最大风险）：
- 退回到 Enter 键方案，也是一个完整可用的工具
- 口播说 "Globe 键需要 Accessibility 权限配置，完整版虾BB 已经处理好了"
- 展示正式版虾BB 作为对比

## 事前 Checklist

- [ ] Kimi Code 安装好并能正常运行
- [ ] `GEMINI_API_KEY` 环境变量设好
- [ ] Xcode Command Line Tools 安装好（`xcode-select --install`）
- [ ] 麦克风权限已授予 Terminal
- [ ] Accessibility 权限已授予 Terminal
- [ ] OBS 录屏 + 人脸画面设好
- [ ] 计时器 app 准备好
- [ ] 跑通一遍完整流程，记录实际耗时
- [ ] 如果 >35分钟，简化 prompt 或预写部分代码


path: content/skin-mockups.html
content: <!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>ClawBB / 虾BB — Skin Mockups</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #1a1a2e; font-family: -apple-system, system-ui, sans-serif; padding: 40px; color: #fff; }
h1 { text-align: center; font-size: 36px; margin-bottom: 10px; }
.subtitle { text-align: center; color: #888; margin-bottom: 50px; font-size: 16px; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 50px; max-width: 1200px; margin: 0 auto; }
.skin-card { background: rgba(255,255,255,0.03); border-radius: 20px; padding: 36px; border: 1px solid rgba(255,255,255,0.06); }
.skin-name { font-size: 20px; font-weight: 700; margin-bottom: 4px; }
.skin-desc { font-size: 12px; color: #666; margin-bottom: 20px; }
.states { display: flex; flex-direction: column; gap: 14px; }
.state-label { font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 4px; }

/* HUD base */
.hud {
  display: inline-flex; align-items: center; gap: 8px;
  padding: 10px 16px; font-size: 14px; font-weight: 500;
  min-width: 340px; position: relative;
}
.hud .logo { width: 28px; height: 28px; object-fit: contain; flex-shrink: 0; }
.hud .bars { display: flex; align-items: center; gap: 2px; height: 22px; }
.hud .bar { width: 2.5px; border-radius: 2px; animation: pulse 0.8s ease-in-out infinite alternate; }
.hud .bar:nth-child(1) { height: 5px; animation-delay: 0s; }
.hud .bar:nth-child(2) { height: 10px; animation-delay: 0.15s; }
.hud .bar:nth-child(3) { height: 15px; animation-delay: 0.3s; }
.hud .bb {
  position: absolute; top: 2px; right: auto; left: 30px;
  font-size: 7px; font-weight: 900; font-family: 'Futura', sans-serif;
  transform: rotate(-12deg);
}
.hud .logo.glow { filter: drop-shadow(0 0 8px currentColor); }

@keyframes pulse { 0% { transform: scaleY(0.3); } 100% { transform: scaleY(1); } }

/* SKINS */

/* 1. Lobster Red */
.s-lobster .hud { background: rgba(30,12,12,0.93); border: 1px solid rgba(239,68,68,0.15); border-radius: 12px; }
.s-lobster .recording .bar { background: #ef4444; }
.s-lobster .recording .text { color: #fff; }
.s-lobster .done .text { color: #fff; }
.s-lobster .done .bb { color: #34d399; }
.s-lobster .done .hud { border-color: rgba(52,211,153,0.2); }

/* 2. Ocean Blue */
.s-ocean .hud { background: rgba(8,18,42,0.94); border: 1px solid rgba(56,130,255,0.15); border-radius: 14px; box-shadow: 0 8px 32px rgba(0,80,200,0.12); }
.s-ocean .recording .bar { background: #60a5fa; }
.s-ocean .recording .text { color: #c7dfff; }
.s-ocean .done .text { color: #c7dfff; }
.s-ocean .done .bb { color: #34d399; }
.s-ocean .done .hud { border-color: rgba(52,211,153,0.2); }

/* 3. Sunset Orange */
.s-sunset .hud { background: linear-gradient(135deg, rgba(40,16,8,0.94), rgba(50,22,10,0.94)); border: 1px solid rgba(251,146,60,0.15); border-radius: 14px; box-shadow: 0 8px 32px rgba(200,80,0,0.08); }
.s-sunset .recording .bar { background: #fb923c; }
.s-sunset .recording .text { color: #fef3c7; }
.s-sunset .done .text { color: #fef3c7; }
.s-sunset .done .bb { color: #a3e635; }
.s-sunset .done .hud { border-color: rgba(163,230,53,0.2); }

/* 4. Chill Green */
.s-chill .hud { background: rgba(10,28,16,0.92); border: 1px solid rgba(52,211,153,0.12); border-radius: 16px; box-shadow: 0 8px 32px rgba(0,150,80,0.06); }
.s-chill .recording .bar { background: #34d399; }
.s-chill .recording .text { color: #d1fae5; }
.s-chill .done .text { color: #d1fae5; }
.s-chill .done .bb { color: #a78bfa; }
.s-chill .done .hud { border-color: rgba(167,139,250,0.2); }

/* 5. Minimal Light */
.s-minimal .hud { background: rgba(248,248,250,0.96); border: 1px solid rgba(0,0,0,0.08); border-radius: 20px; box-shadow: 0 4px 20px rgba(0,0,0,0.06); }
.s-minimal .recording .bar { background: #dc2626; }
.s-minimal .recording .text { color: #1e1e2e; }
.s-minimal .done .text { color: #1e1e2e; }
.s-minimal .done .bb { color: #16a34a; }

/* 6. Cyberpunk */
.s-cyber .hud { background: rgba(10,0,30,0.95); border: 1px solid rgba(168,85,247,0.25); border-radius: 6px; box-shadow: 0 0 20px rgba(168,85,247,0.12), inset 0 0 20px rgba(168,85,247,0.04); }
.s-cyber .recording .bar { background: #e879f9; box-shadow: 0 0 6px #e879f9; }
.s-cyber .recording .text { color: #e0f2fe; font-family: 'SF Mono', 'Fira Code', monospace; letter-spacing: 0.5px; }
.s-cyber .done .text { color: #e0f2fe; font-family: 'SF Mono', 'Fira Code', monospace; }
.s-cyber .done .bb { color: #4ade80; }
.s-cyber .done .hud { border-color: rgba(74,222,128,0.25); box-shadow: 0 0 20px rgba(74,222,128,0.08); }

/* 7. Glassmorphism */
.s-glass .hud { background: rgba(255,255,255,0.08); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid rgba(255,255,255,0.15); border-radius: 16px; box-shadow: 0 8px 32px rgba(0,0,0,0.15); }
.s-glass .recording .bar { background: rgba(248,113,113,0.8); }
.s-glass .recording .text { color: rgba(255,255,255,0.9); }
.s-glass .done .text { color: rgba(255,255,255,0.9); }
.s-glass .done .bb { color: #4ade80; }

/* 8. Kawaii Pink */
.s-kawaii .hud { background: linear-gradient(135deg, rgba(50,15,35,0.94), rgba(40,10,30,0.94)); border: 1px solid rgba(244,114,182,0.2); border-radius: 18px; box-shadow: 0 8px 32px rgba(200,50,120,0.08); }
.s-kawaii .recording .bar { background: #f472b6; border-radius: 3px; }
.s-kawaii .recording .text { color: #fce7f3; }
.s-kawaii .done .text { color: #fce7f3; }
.s-kawaii .done .bb { color: #c084fc; }
.s-kawaii .done .hud { border-color: rgba(192,132,252,0.2); }

/* === LIGHT / WHITE SERIES === */

/* Paper */
.s-paper .hud { background: rgba(252,250,245,0.96); border: 1px solid rgba(160,130,80,0.12); border-radius: 14px; box-shadow: 0 4px 16px rgba(100,80,40,0.06); }
.s-paper .recording .bar { background: #92400e; }
.s-paper .recording .text { color: #44403c; }
.s-paper .done .text { color: #44403c; }
.s-paper .done .bb { color: #92400e; }

/* Snow */
.s-snow .hud { background: rgba(245,248,255,0.97); border: 1px solid rgba(148,163,184,0.15); border-radius: 16px; box-shadow: 0 4px 16px rgba(100,120,150,0.06); }
.s-snow .recording .bar { background: #3b82f6; }
.s-snow .recording .text { color: #334155; }
.s-snow .done .text { color: #334155; }
.s-snow .done .bb { color: #3b82f6; }

/* Milk Tea */
.s-milk .hud { background: rgba(255,250,240,0.96); border: 1px solid rgba(180,150,100,0.12); border-radius: 18px; box-shadow: 0 4px 16px rgba(120,90,40,0.05); }
.s-milk .recording .bar { background: #b45309; }
.s-milk .recording .text { color: #3f3f46; }
.s-milk .done .text { color: #3f3f46; }
.s-milk .done .bb { color: #b45309; }

/* === PINK SERIES === */

/* Rose */
.s-rose .hud { background: rgba(45,12,25,0.94); border: 1px solid rgba(255,143,171,0.18); border-radius: 16px; box-shadow: 0 8px 32px rgba(200,60,100,0.08); }
.s-rose .recording .bar { background: #ff8fab; }
.s-rose .recording .text { color: #fce7f3; }
.s-rose .done .text { color: #fce7f3; }
.s-rose .done .bb { color: #c4b5fd; }

/* Coral */
.s-coral .hud { background: rgba(42,18,15,0.93); border: 1px solid rgba(255,111,97,0.15); border-radius: 14px; box-shadow: 0 8px 32px rgba(200,70,60,0.08); }
.s-coral .recording .bar { background: #ff6f61; }
.s-coral .recording .text { color: #fef2f2; }
.s-coral .done .text { color: #fef2f2; }
.s-coral .done .bb { color: #34d399; }

/* Hot Pink */
.s-hotpink .hud { background: rgba(40,5,20,0.95); border: 1px solid rgba(255,64,129,0.2); border-radius: 10px; box-shadow: 0 0 20px rgba(255,64,129,0.1); }
.s-hotpink .recording .bar { background: #ff4081; box-shadow: 0 0 4px #ff4081; }
.s-hotpink .recording .text { color: #fce4ec; }
.s-hotpink .done .text { color: #fce4ec; }
.s-hotpink .done .bb { color: #ba93ff; }

/* Peach */
.s-peach .hud { background: rgba(48,20,14,0.93); border: 1px solid rgba(255,183,148,0.15); border-radius: 16px; box-shadow: 0 8px 32px rgba(200,100,50,0.06); }
.s-peach .recording .bar { background: #ffb794; }
.s-peach .recording .text { color: #fff7ed; }
.s-peach .done .text { color: #fff7ed; }
.s-peach .done .bb { color: #c084fc; }

/* === SPECIAL === */

/* Claude */
.s-claude .hud { background: rgba(42,36,30,0.95); border: 1px solid rgba(217,119,62,0.2); border-radius: 14px; box-shadow: 0 8px 32px rgba(180,90,30,0.1); }
.s-claude .recording .bar { background: #d9773e; }
.s-claude .recording .text { color: #fcf5eb; }
.s-claude .done .text { color: #fcf5eb; }
.s-claude .done .bb { color: #4ade80; }
</style>
</head>
<body>

<h1><img src="logos/logo-white.png" style="height:36px;vertical-align:middle"> 虾BB / ClawBB — Skin Mockups</h1>
<p class="subtitle">HUD 皮肤设计方案 — 录音状态 + 完成状态 | 使用产品 logo，非 emoji</p>

<div class="grid">

<!-- TEMPLATE MACRO via JS -->
</div>

<script>
const skins = [
  // === DARK SERIES ===
  { id:'lobster', name:'🦞 Lobster Red / 龙虾红', desc:'Default — 经典品牌色', recLogo:'red', doneLogo:'green', cardBg:'' },
  { id:'ocean', name:'🌊 Ocean Blue / 大海蓝', desc:'深邃宁静', recLogo:'blue', doneLogo:'green', cardBg:'' },
  { id:'sunset', name:'🌅 Sunset Orange / 夏日橙', desc:'温暖活力', recLogo:'orange', doneLogo:'lime', cardBg:'' },
  { id:'chill', name:'🌿 Chill Green / 休闲绿', desc:'自然放松', recLogo:'green', doneLogo:'purple', cardBg:'' },
  { id:'cyber', name:'💜 Cyberpunk / 赛博朋克', desc:'霓虹光效', recLogo:'purple', doneLogo:'cyan', cardBg:'' },
  { id:'glass', name:'🪟 Glass / 毛玻璃', desc:'半透明磨砂', recLogo:'red', doneLogo:'cyan', cardBg:'background:linear-gradient(135deg,rgba(100,50,150,0.3),rgba(50,100,200,0.3))' },

  // === LIGHT / WHITE SERIES ===
  { id:'minimal', name:'⬜ Minimal / 极简白', desc:'Apple 极简风', recLogo:'red', doneLogo:'dark', cardBg:'background:rgba(255,255,255,0.06)' },
  { id:'paper', name:'📄 Paper / 纸白', desc:'温暖纸张质感', recLogo:'brown', doneLogo:'dark', cardBg:'background:rgba(255,255,255,0.06)' },
  { id:'snow', name:'❄️ Snow / 雪白', desc:'冷色调纯白', recLogo:'blue', doneLogo:'slate', cardBg:'background:rgba(255,255,255,0.06)' },
  { id:'milk', name:'🥛 Milk Tea / 奶茶白', desc:'温柔米色底', recLogo:'brown', doneLogo:'charcoal', cardBg:'background:rgba(255,255,255,0.06)' },

  // === PINK / GIRLY SERIES ===
  { id:'kawaii', name:'🌸 Kawaii Pink / 可爱粉', desc:'软萌少女心', recLogo:'pink', doneLogo:'purple', cardBg:'' },
  { id:'rose', name:'🌹 Rose / 玫瑰粉', desc:'优雅玫瑰色', recLogo:'rose', doneLogo:'lavender', cardBg:'' },
  { id:'coral', name:'🐚 Coral / 珊瑚粉', desc:'清新珊瑚色', recLogo:'coral', doneLogo:'cyan', cardBg:'' },
  { id:'hotpink', name:'💗 Hot Pink / 辣粉', desc:'大胆亮粉', recLogo:'hotpink', doneLogo:'lavender', cardBg:'' },
  { id:'peach', name:'🍑 Peach / 蜜桃粉', desc:'甜蜜蜜桃色', recLogo:'peach', doneLogo:'purple', cardBg:'' },

  // === SPECIAL ===
  { id:'claude', name:'🤖 Claude / AI 助手', desc:'Anthropic 品牌配色', recLogo:'claude-orange', doneLogo:'cyan', cardBg:'' },
];

const grid = document.querySelector('.grid');
skins.forEach(s => {
  grid.innerHTML += `
  <div class="skin-card s-${s.id}" ${s.cardBg ? 'style="'+s.cardBg+'"' : ''}>
    <div class="skin-name">${s.name}</div>
    <div class="skin-desc">${s.desc}</div>
    <div class="states">
      <div>
        <div class="state-label">Recording 录音中</div>
        <div class="recording">
          <div class="hud">
            <img class="logo glow" src="logos/logo-${s.recLogo}.png" alt="logo">
            <div class="bars"><div class="bar"></div><div class="bar"></div><div class="bar"></div></div>
            <div class="bars" style="transform:scaleX(-1)"><div class="bar"></div><div class="bar"></div><div class="bar"></div></div>
            <span class="text">帮我查一下 OpenClaw 有什么新的...</span>
          </div>
        </div>
      </div>
      <div>
        <div class="state-label">Done 完成</div>
        <div class="done">
          <div class="hud">
            <img class="logo" src="logos/logo-${s.doneLogo}.png" alt="logo">
            <span class="bb">BB!</span>
            <span class="text">帮我查一下 OpenClaw 有什么新的 skills</span>
          </div>
        </div>
      </div>
    </div>
  </div>`;
});
</script>

<p style="text-align:center; color:#555; margin-top:50px; font-size:13px;">
  虾BB / ClawBB — Skin System v1<br>
  Each skin: background, corner radius, text color, recording color, success color, bar style, logo tint
</p>

</body>
</html>


path: content/twitter-zhihu-raw.md
content: # Content 1: Twitter/X Thread

---

**Tweet 1 (hook)**
I built a free macOS voice-to-text app in one night that works better than Siri dictation.

280KB. Zero dependencies. Pure Swift.

It speaks Chinese AND English, and the name is a bilingual pun.

Here's the story 🧵

---

**Tweet 2**
The problem: every good voice input tool on Mac is either:

• Siri dictation (decent but clunky, no global hotkey)
• Whisper (great accuracy, but you're running Python, ffmpeg, and a 1.5GB model locally)
• Typeless ($12/mo for what should be a system feature)

I wanted none of that.

---

**Tweet 3**
So I built ClawBB (虾BB).

It sits in your menu bar. Hold Globe key → speak → release → text appears wherever your cursor is.

That's it. No app switching. No UI to interact with. It just works.

Works in ANY app: Notion, Xcode, Terminal, browsers, everything.

---

**Tweet 4**
The name 虾BB is a bilingual pun.

虾 = shrimp (our company mascot, ClawHire 龙虾招聘)
BB = baby? No.

In Cantonese slang, "BB" means "talking" — so 虾BB = a shrimp that talks.

But phonetically in Mandarin, 瞎BB means "talking nonsense."

The name is both the product and the warning label.

---

**Tweet 5**
Technical stack:

• Pure Swift, zero dependencies
• Dual-engine: Gemini Live API (streaming, low latency) + Gemini REST (final, accurate)
• Flash-Lite for real-time translation to your target language
• CGEvent tap for global hotkey capture
• AXUIElement for text injection — no clipboard pollution

280KB total binary.

---

**Tweet 6**
The dual-engine architecture is the key insight.

While you're still speaking → streaming Gemini Live shows a live preview HUD.
When you release the key → REST Gemini fires, returns final accurate text, injects it.

You get BOTH speed feedback AND accuracy. Most tools pick one.

---

**Tweet 7**
vs Typeless ($12/mo):
✅ ClawBB is free, forever
✅ Same global hotkey UX
✅ Bilingual CN/EN in one session
❌ Typeless has a polished settings UI (ours is JSON config for now)

vs local Whisper:
✅ No 1.5GB model download
✅ No Python environment
❌ Whisper works offline (ClawBB needs internet + Gemini API key)

---

**Tweet 8**
The hardest bug: Accessibility permissions on macOS.

CGEvent tap silently fails if you don't have Accessibility access. No error. No crash. Just... nothing.

Spent 45 minutes wondering why Globe key wasn't registering before realizing the permission prompt never appeared.

Now we check and prompt properly on launch.

---

**Tweet 9**
Built entirely with Claude Code in one night.

No prior Swift Audio experience. I described what I wanted, it scaffolded the architecture, I debugged the edge cases with it.

This is what AI-assisted dev actually looks like in 2025: you still need to understand the system, but the surface area you can cover in a night is insane.

---

**Tweet 10 (CTA)**
ClawBB is free and open source.

→ github.com/dyz2102/clawbb

You need a Gemini API key (free tier works). Install takes ~2 minutes.

If you do voice notes, dictat code comments, or just hate typing — try it.

RT if you know a Mac user who'd find this useful.

---

---

# Content 2: 知乎 Article

---

**一个晚上，用AI做了一个比Siri好用的免费语音输入工具**

*ClawBB（虾BB）开发全记录：双引擎架构、踩坑日记、以及为什么叫"瞎BB"*

---

## 一、先说结论

我在一个晚上，用Claude Code从零写了一个macOS语音输入工具，叫做ClawBB（虾BB）。

它的核心特性：

- **全局热键**：按住Globe键说话，松开自动注入文字，任何应用都支持
- **双语混输**：中英文混说，自动识别，不需要切换输入法
- **双引擎架构**：说话时Gemini Live实时预览，松键后REST接口输出最终准确结果
- **极致轻量**：280KB，纯Swift，零依赖，不需要Python环境，不需要下载1.5GB的本地模型
- **完全免费开源**：github.com/dyz2102/clawbb

这篇文章是完整的开发记录，包括技术方案、踩坑经历、竞品分析，以及这个名字为什么叫"虾BB"。

---

## 二、为什么现有工具都不够好用

在开始写代码之前，我用过市面上几乎所有的Mac语音输入方案，逐一说说问题在哪。

**macOS自带听写（Siri Dictation）**

系统自带，不需要安装任何东西，按两次fn键激活。准确率其实还不错，中英文都支持。

但问题是体验割裂：它没有全局热键的"按住说话、松手注入"逻辑。你需要先点击输入框，激活听写，说完，等它处理。如果你在写代码、在Notion里做笔记、在Terminal里输命令，频繁切换焦点非常烦。

更关键的是：它没有实时反馈。你说了很长一段话，屏幕上什么都没有，你不知道它有没有在听。

**本地Whisper**

OpenAI的Whisper模型在准确率上是目前最好的方案之一，尤其是large-v3模型，中英文效果都很强。

但门槛很高：你需要Python环境、ffmpeg、以及至少1.5GB的模型文件。对于非技术用户来说，光是配置环境就要花半天。而且本地推理对CPU/GPU有一定压力，老机器上延迟明显。

**Whisper-based的GUI工具**

比如WhisperMic、MacWhisper等，把Whisper包装成GUI，降低了使用门槛。但本质问题没变：还是本地推理，还是大模型文件，而且部分工具要收费。

**Typeless**

这是目前我见过的体验最好的商业产品。全局热键、菜单栏驻留、实时HUD预览，该有的都有。准确率也很好，背后用的是云端语音API。

但它要$12/月。对于一个"我只是想偶尔语音输入"的需求来说，$12/月真的很难说服自己。

**讯飞输入法**

中文识别准确率很高，在国内有大量用户基础。但macOS版本的体验相比iOS版本差很多，更新也不积极。而且它是完整的输入法，要替换掉系统输入法，很多人不愿意这么做。

**我要的是什么？**

想了想，我的需求其实很简单：

1. 按住一个键说话，松开自动填到光标位置
2. 中英文混说，不需要手动切模式
3. 有实时反馈，知道它在听
4. 轻量，不要装一堆依赖
5. 免费

这四个需求，没有一个现有工具能同时满足。那就自己做。

---

## 三、技术方案设计

花了大概20分钟想清楚架构，核心问题是：**语音识别用什么，文字注入怎么做。**

### 3.1 语音识别：Gemini双引擎

最初想用OpenAI Whisper API（不是本地模型，是云端API），便宜、准确率高。但Whisper API有一个问题：它是非流式的，你必须把整段音频发上去，才能拿到结果。对于"实时预览"的需求来说，这行不通。

Google的Gemini有两个适合的接口：

**Gemini Live API**：流式双向音频，延迟极低，说话时就能实时返回识别结果。这是做实时HUD预览的关键。

**Gemini REST API（音频转文字）**：把录完的音频发上去，返回准确的最终结果。准确率比Live API更高（Live API为了低延迟会做一些取舍）。

两个接口各有优势，所以我做了双引擎架构：

```
用户按住Globe键 → 开始录音
        ↓
Gemini Live API（流式）→ 实时更新HUD预览文字
        ↓
用户松开Globe键 → 停止录音
        ↓
Gemini REST API → 返回最终准确文字
        ↓
AXUIElement注入 → 文字出现在光标位置
```

这样你说话的时候有实时反馈（Live API），松手后注入的是最准确的版本（REST API）。

另外加了Flash-Lite做翻译：如果你设置了目标语言（比如只要中文输出），Gemini在返回识别结果时会顺便翻译。这对于开会时说英语、但要输中文的场景很有用。

### 3.2 文字注入：AXUIElement而不是剪贴板

很多语音输入工具用的是剪贴板注入：把识别结果复制到剪贴板，然后模拟Cmd+V粘贴。

这个方案的问题：
- 会破坏用户当前剪贴板的内容
- 在某些应用里Cmd+V的行为不是"粘贴纯文本"
- 在Terminal里粘贴会触发确认提示（如果内容有换行）

我用的是macOS Accessibility框架里的`AXUIElement`：直接向当前聚焦的文本输入框写入文字，不经过剪贴板。

```swift
func injectText(_ text: String) {
    guard let element = AXUIElementCreateSystemWide()
        .focusedElement else { return }
    
    // 获取当前值，在光标位置插入新文字
    var currentValue: AnyObject?
    AXUIElementCopyAttributeValue(element, kAXValueAttribute as CFString, &currentValue)
    
    let insertionPoint = getInsertionPoint(element)
    let newValue = insert(text, into: currentValue as? String ?? "", at: insertionPoint)
    
    AXUIElementSetAttributeValue(element, kAXValueAttribute as CFString, newValue as CFTypeRef)
}
```

这个方案的效果是：文字像是从键盘输入的，不影响剪贴板，在任何文本输入框都能用。

### 3.3 全局热键：CGEvent Tap

Globe键（地球键）在macOS上的键码是kVK_Function（63），需要用CGEvent Tap来全局捕获，不受当前聚焦应用影响。

---

## 四、用Claude Code开发的过程

我没有写过Swift音频处理的代码。整个项目从零开始，用的是Claude Code。

整体流程大概是：

**第一步（约30分钟）**：描述需求，让Claude Code搭骨架。

我告诉它：macOS菜单栏应用，监听Globe键，按住录音，松手注入。它生成了项目结构、`AppDelegate`、基础的`AVAudioEngine`录音逻辑、以及菜单栏的`NSStatusItem`。

**第二步（约1小时）**：接入Gemini API。

Live API的WebSocket协议文档比较新，Claude Code对最新版本不太熟，这部分我需要参考Google的官方文档，然后让它根据文档实现。REST接口更简单，直接描述需求它就写对了。

**第三步（约2小时）**：调试踩坑。

这是花时间最多的部分，下面单独说。

**第四步（约30分钟）**：UI和配置。

HUD预览窗口（一个半透明浮窗，显示实时识别文字）、菜单栏图标状态切换、JSON配置文件支持（API key、目标语言、热键等）。

Claude Code在这些部分非常高效，样板代码写得又快又标准。

---

## 五、踩坑记录

这部分是整个开发过程中最有意思的，也是最容易让人放弃的。

### 坑1：Accessibility权限的静默失败

CGEvent Tap需要系统的Accessibility权限。如果没有这个权限，`CGEvent.tapCreate`不会报错，不会崩溃，只是返回`nil`，然后什么都不发生。

我花了将近一个小时在调试"Globe键为什么没有反应"，检查了键码、检查了回调注册逻辑，全都是对的。最后才发现是权限问题——权限弹窗根本没有出现，因为应用没有在正确的时机请求权限。

正确的做法是在`applicationDidFinishLaunching`里主动检查并请求：

```swift
func checkAccessibilityPermission() -> Bool {
    let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true]
    return AXIsProcessTrustedWithOptions(options as CFDictionary)
}
```

如果返回`false`，就引导用户去系统设置 → 隐私与安全性 → 辅助功能手动开启。

而且还有一个坑：添加权限后，需要重启应用才生效。这一点一定要在UI里说清楚，否则用户会以为应用有bug。

### 坑2：CGEvent Tap超时

macOS对CGEvent Tap有超时限制：如果你的回调处理时间超过一定阈值，系统会认为这个tap无响应，自动禁用它。

问题是我最初在回调里做了一些同步操作（检查当前应用、判断是否在文本框里），导致偶发性的tap被禁用。

解决方案是把所有非关键逻辑移到异步队列：

```swift
func eventTapCallback(event: CGEvent) -> CGEvent? {
    // 只在回调里做最轻量的判断
    let keyCode = event.getIntegerValueField(.keyboardEventKeycode)
    guard keyCode == kGlobeKeyCode else { return event }
    
    // 其余逻辑异步处理
    DispatchQueue.main.async {
        self.handleGlobeKeyEvent(event.type)
    }
    
    return nil // 吃掉这个事件，不传给其他应用
}
```

### 坑3：Flash-Lite翻译的语言代码bug

Gemini Flash-Lite支持在识别的同时做翻译，在prompt里指定目标语言即可。但我发现一个问题：用`zh-CN`指定中文时，有时返回的是繁体字；用`Chinese`指定时，有时返回英文。

最终发现需要在prompt里更明确地描述：

```
Transcribe the audio. Output in Simplified Chinese (简体中文) only. 
If the audio is in English, translate to Simplified Chinese.
Do not output any explanation, only the transcribed/translated text.
```

光靠语言代码不够可靠，需要在prompt里明确说"简体中文"。

### 坑4：HUD窗口的焦点抢占

最初的HUD实现用的是普通`NSWindow`，结果每次弹出预览窗口，都会抢走当前应用的输入焦点。用户一边说话，输入框焦点已经跑掉了，松手后文字没地方注入。

解决方案：把HUD窗口设置为`NSPanel`，并设置`becomesKeyOnlyIfNeeded = false`、`hidesOnDeactivate = false`，同时把window level设置为`floating`。这样HUD只是悬浮显示，不抢焦点。

```swift
let panel = NSPanel(
    contentRect: .zero,
    styleMask: [.nonactivatingPanel, .borderless],
    backing: .buffered,
    defer: false
)
panel.level = .floating
panel.isFloatingPanel = true
panel.becomesKeyOnlyIfNeeded = false
```

---

## 六、竞品横向对比

| 维度 | ClawBB | Typeless | 本地Whisper | 讯飞输入法 |
|------|--------|----------|-------------|------------|
| 价格 | 免费 | $12/月 | 免费 | 免费（有付费版） |
| 安装体积 | 280KB | ~50MB | 1.5GB+ | ~200MB |
| 实时预览 | 有 | 有 | 无（部分工具有） | 有 |
| 中英混输 | 支持 | 支持 | 支持 | 中文为主 |
| 离线使用 | 不支持 | 不支持 | 支持 | 部分支持 |
| 全局热键 | 支持 | 支持 | 依赖工具 | 支持 |
| 开源 | 是 | 否 | 是（模型） | 否 |
| 剪贴板污染 | 无 | 无 | 有些有 | 无 |

Typeless在产品体验上确实打磨得更好，设置界面、多语言配置都比我的JSON配置文件友好很多。如果你不介意$12/月，Typeless是更完善的商业产品。

ClawBB的优势是：**免费、轻量、开源、可自部署、可二次开发**。技术背景的用户会更喜欢这种方式。

---

## 七、虾BB这个名字的由来

我们公司做的是招聘产品，叫龙虾招聘（ClawHire），虾是我们的吉祥物。

给这个工具起名的时候想：一个会说话的虾，广东话里"BB"有"讲嘢"（说话）的意思，所以虾BB = 一只会说话的虾，很直觉。

但发现粤语梗对北方用户不够友好，想了想，普通话谐音：虾BB，念快一点，就是"瞎BB"——瞎说、乱讲。

一个工具，两个解释：官方解释是"会说话的虾"，非官方解释是"用来瞎BB的工具"。

对语音输入来说，"瞎BB"反而是最准确的描述：你就是在瞎说，让它帮你整理成文字。名字本身就是一种免责声明。

---

## 八、开源发布

项目地址：**github.com/dyz2102/clawbb**

使用前提：
- macOS 13.0+
- 一个Gemini API Key（去 aistudio.google.com 免费申请，免费额度对个人使用完全够）

配置方式很简单，在`~/.clawbb/config.json`里写：

```json
{
  "gemini_api_key": "YOUR_KEY_HERE",
  "target_language": "zh-CN",
  "hotkey": "globe"
}
```

启动后在菜单栏会看到一个虾的图标，按住Globe键说话，松手文字自动填到光标位置。

---

## 九、后记

整个项目从0到能用，花了大概一个晚上（6-7小时）。其中调试踩坑占了一半时间，真正写新功能的时间并不多。

用AI写代码的感受：**你不需要知道所有API的用法，但你必须知道系统在做什么**。Accessibility权限的问题、CGEvent Tap的超时机制、NSPanel和NSWindow的区别——这些都需要有一定的macOS开发背景才能快速定位。AI负责写代码，你负责理解系统。

这个分工在效率上确实有质的飞跃。一个没有Swift音频经验的人，一个晚上做出一个能用的工具，放在三年前是不可能的事。

如果你有macOS，有Gemini API Key，愿意花10分钟配置——试试看。如果有问题或者建议，欢迎在GitHub开Issue。

---

*本文作者是龙虾招聘（ClawHire）的创始人，ClawBB是内部工具开源，不代表公司产品路线。*


path: content/video-additions.md
content: # 视频脚本补充内容 — 2026-03-21

## 新增段落1：为什么用 LLM 而不是 Whisper（插入到架构讲解后）

[画面：Whisper vs Gemini 对比表]

很多人会问：为什么不用 Whisper？Whisper 是免费的啊，还不需要网络。

我跟你们说说我的亲身体验。我用 MacWhisper 跑 Whisper large-v3-turbo，说了一段中英文混的话。

[屏幕录制：MacWhisper 输出结果，没有标点，有错别字]

你看到了吗？没有标点符号，"帮我"变成了"棒我"，完全没有语义理解。

为什么？因为 Whisper 是一个纯 ASR 模型——它只做声音到文字的机械转换。它不理解你在说什么。

[画面：字幕强调]

**Whisper = 听写机器人，只听不懂**
**Gemini = 一个懂语言的人在帮你记录**

Gemini 是一个大语言模型，它先听懂你说的话，再输出文字。所以它有完美的标点符号，能纠正口音导致的错误，中英文切换也很自然。

而且 Whisper large-v3 要占 3GB 硬盘 + 4GB 内存来跑。ClawBB 整个 app 只有 280KB。

[画面：280KB vs 3GB 的视觉对比]

当然，Whisper 的优势是离线、隐私。以后我们可以加一个离线模式，用 whisper.cpp 的 tiny 模型（75MB）做 fallback。但主力一定是 LLM。

---

## 新增段落2：国内用户怎么用（插入到结尾前）

[画面：Z 面向镜头]

我知道很多观众是在国内的，用不了 Claude Code，也用不了 Google Gemini。

别慌，有完整的替代方案。

[画面：对比表]

**编程工具替代：**
- Claude Code → **Kimi Code**（月之暗面出的，命令行工具，免费，体验类似）
- 或者用 **Trae**（字节跳动出的免费 IDE）

**语音 API 替代：**
- Google Gemini → **阿里云 Paraformer**（每天 2 小时免费，中文识别极强）
- 或者 **讯飞**（新用户 10 万次免费）

代码改动很小，就是把 API 地址和认证方式换一下。

[画面：GitHub 链接]

我在 GitHub 上会放一个国内版的配置指南，你们可以直接参考。

---

## 新增段落3：皮肤系统预告（插入到结尾）

[画面：皮肤 mockup 页面，各种颜色的 HUD]

对了，我们还做了一个皮肤系统——16 款 HUD 皮肤。龙虾红、大海蓝、赛博朋克、毛玻璃……你知道搜狗输入法最成功的是什么吗？皮肤。我们也要把这个玩起来。

[画面：Z 笑]

以后你甚至可以自己设计皮肤，分享给其他虾BB用户。

---

## 视频工具推荐总结

| 场景 | 国际用户 | 国内用户 |
|------|---------|---------|
| AI 编程 | Claude Code | Kimi Code / Trae |
| 语音 API | Google Gemini | 阿里云 Paraformer |
| 模型 | Claude Opus/Sonnet | Kimi K2.5 / DeepSeek |
| 代码托管 | GitHub | GitHub（国内能访问） |


path: content/video-script-final.md
content: # 虾BB / ClawBB — 最终视频脚本

> **标题**: "我用AI一晚上做了个比Siri好用的语音输入工具，然后开源了"
> **时长**: 12-15分钟
> **风格**: Learn in public，真实叙事，不是教程是故事
> **参考**: 张咋啦 Zara 的 "文科生学AI" 风格 — 非技术背景也能做

---

## HOOK — 前10秒决定生死 (0:00-0:10)

[画面：屏幕录制，手指按住 Globe 键]

**Z**: 我按住这个键——

[说话] "帮我回复客户说 Q1 report 已经发了，让他 check email"

[松开，文字瞬间出现在光标处，中英混排完美]

**Z**: 两秒。中英混排。免费。

[切到面对镜头]

**Z**: 这个工具我昨晚做的。叫虾BB。

---

## 为什么要做这个 (0:10-1:30)

[面对镜头，节奏快]

**Z**: 先说一个数据。

[画面：数据卡片动画]

- 语音输入速度：150字/分钟
- 打字速度：40字/分钟
- 语音是打字的 **3.75倍**

[画面：36氪标题 "AI语音输入法，人类进入不打字时代"]

**Z**: 36氪今年1月发了一篇文章，标题叫"人类进入不打字时代"。搜狗输入法日均语音使用近20亿次。到2028年，伦敦政经学院预测，AI交互将主要以语音为主。

但是——

[画面：切到 Siri 翻车截图]

**Z**: 你试过对 Siri 说一句中英混的话吗？

"帮我写封 email，subject 是 project update"

[展示 Siri 的翻车结果]

**Z**: 它给我搜索了。

[画面：Whisper 翻车]

**Z**: Whisper 呢？我用 MacWhisper 跑 large-v3-turbo。没有标点符号，经常错别字。为什么？因为 Whisper 是一个 ASR 模型——它只"听"不"懂"。它做的是声音到文字的机械转换，不理解你在说什么。

[画面：Typeless 定价页]

**Z**: 付费工具呢？Typeless，$12/月。Wispr Flow，$15/月。

[停顿，直视镜头]

**Z**: 我就想要一个免费的、中英混搭好用的、不卡的语音输入。市面上没有。那就自己做。

---

## 关键 Insight — 为什么用 LLM 而不是 ASR (1:30-3:00)

[画面：白板/示意图]

**Z**: 这是整个视频最重要的一个 insight，你记住这一点就行。

[画面：大字幕]

**Whisper = 听写机器人，只听不懂**
**Gemini = 一个懂语言的人在帮你记录**

**Z**: Whisper 是 ASR（自动语音识别），它做的是声学匹配——把声波波形映射到文字。所以它没有标点，没有语义纠错，中英切换容易乱。

Gemini 是一个大语言模型。它不是在"识别"你说的话，它是在"理解"你说的话，然后输出文字。所以它有完美的标点，能纠正口音错误，中英文切换自然。

[画面：对比表]

| | Whisper | Gemini |
|---|---|---|
| 标点 | 没有 | 完美 |
| 错别字 | 多 | 少 |
| 中英混搭 | 一般 | 很好 |
| 模型大小 | 3GB | 0（云端） |
| 速度 | 3-5秒 | 1-2秒 |
| 价格 | 免费但要本地跑 | 免费250次/天 |

**Z**: 而且 Gemini 的免费 API，理论上支持最长 9.5 小时的单次音频。我自己测过好几分钟的连续录音，完全没问题。MacWhisper 长音频经常出错。

---

## 一晚上怎么做出来的 (3:00-7:00)

[画面：屏幕录制 + 画外音]

**Z**: 好，接下来我带你们走一遍我是怎么做的。

### 第一步：架构想清楚 (30秒)

[画面：简洁示意图]

**Z**: 架构很简单。

按住 Globe 键 → 开始录音 → 两个 Gemini 引擎同时跑：
- Live API（WebSocket）实时流式显示文字预览
- REST API 等录完后给最终准确结果

松开 Globe 键 → 文字自动粘贴到光标位置。

就这么简单。

### 第二步：打开 Claude Code，描述需求 (1分钟)

[屏幕录制：打开终端，输入 claude]

**Z**: 我用的是 Claude Code。你可以把它想成一个住在终端里的高级程序员。

我给它的第一个 prompt 大概是这样：

[画面：prompt 文字滚动]

> "做一个 macOS 原生 Swift 菜单栏应用。按住 Globe 键录音，松手转文字，自动粘贴。用 Gemini Live API 做实时预览，REST API 做最终识别。CGEventTap 检测按键，AVAudioEngine 录音。纯 Swift，零依赖。"

[画面：Z 面向镜头]

**Z**: 注意——我把每一个技术细节都告诉它了。API 选型、录音参数、窗口行为。越具体，AI 写出来的越接近你想要的。

[屏幕录制：Claude Code 开始写代码，快进]

**Z**: 大概十分钟，1700 行 Swift 代码出来了。一个人写可能要一周。

### 第三步：编译、测试、踩坑 (2分钟)

[画面：编译报错]

**Z**: 第一次编译，报错。正常。把错误贴给 Claude，它修。再编译，又报错。再贴。来来回回五六次。

[画面：凌晨三点的截图]

**Z**: 凌晨三点，它跑通了。

但接下来是最痛苦的部分——macOS 权限。

---

## 踩坑记录 — 真实的痛 (7:00-10:00)

[画面：Z 表情切换到"回忆痛苦"]

### 坑1：权限地狱 (1分钟)

**Z**: CGEventTap 需要 Accessibility 权限。但是！当你把 app 双击打开的时候，macOS 死活不给权限。不报错，不崩溃，就是——Globe 键没反应。

[画面：权限设置界面]

**Z**: 我调了快两个小时。最后发现，只有从 Terminal.app 启动的进程才能继承 Terminal 的 Accessibility 权限。所以我做了一个 hack——app 检测到没权限，就自动打开一个 Terminal 窗口来运行自己，然后把 Terminal 窗口最小化。

[画面：Z 苦笑]

**Z**: 对，就是这么 hacky。但它 works。

### 坑2：Event Tap 被系统偷偷关掉 (30秒)

**Z**: 更坑的是，macOS 如果觉得你的 Event Tap 回调太慢，会直接把它 disable 掉。不通知你。

解法：加了一个 watchdog，每5秒检查一次，死了就重新启用。

### 坑3：Flash-Lite 把中文翻译成英文 (30秒)

**Z**: 我一开始用的是 Gemini Flash-Lite 模型，因为便宜、额度多。结果我说中文，它给我翻译成英文输出。

[画面：错误输出截图]

**Z**: 换回 Flash，解决。有些钱不能省。

### 坑4：API Key 暴露在 URL 里 (30秒)

**Z**: 安全审计的时候发现——API Key 拼在 URL query string 里。这意味着任何代理、日志都能看到你的 key。

正确做法：放在 HTTP Header 里。这种细节 AI 帮你写代码的时候很容易忽略，一定要做 security review。

---

## 产品打磨 — 情绪价值 (10:00-11:30)

[画面：HUD 各种状态动画]

**Z**: 功能跑通了，但产品体验还差很远。

[展示 HUD 进化过程]

第一版：一个红色圆点。朋友说"这是在报警吗？"

第二版：换成龙虾麦克风 icon + 声波动画。录音时声波往外扩散，识别时声波往内收拢。

[画面：BB! 弹出动画]

**Z**: 完成的时候，icon 变绿，旁边弹出一个小小的 "BB!" —— 虾BB嘛，BB成功了。

[画面：皮肤系统截图]

**Z**: 然后我做了一个皮肤系统。搜狗输入法最成功的是什么？皮肤。龙虾红、大海蓝、赛博朋克、可爱粉……16款。

[画面：Z 认真]

**Z**: 你别觉得这些是花里胡哨。产品的情绪价值，很多时候就是用户留下来的原因。

---

## 国内用户怎么用 (11:30-12:30)

[画面：对比表]

**Z**: 我知道很多人在国内，用不了 Claude Code，也用不了 Gemini。

替代方案：

| 国际版 | 国内版 |
|--------|--------|
| Claude Code | **Kimi Code**（月之暗面，免费） |
| Gemini API | **阿里云 Paraformer**（每天2小时免费） |

**Z**: Kimi Code 是命令行工具，体验跟 Claude Code 几乎一样。Paraformer 中文识别很强，有国际版，不需要翻墙。

代码改动很小，就是换个 API 地址。GitHub 上有配置指南。

---

## 结尾 — 虾BB 的哲学 (12:30-13:30)

[画面：Z 直视镜头，节奏放慢]

**Z**: 最后说一下为什么叫"虾BB"。

虾BB，谐音"瞎BB"——就是瞎说、乱讲。

[画面：字幕，大字]

> **最好的想法，说出来的时候都像在瞎BB。**

**Z**: 语音输入的本质就是——你不用想好再说，你直接说。说错了，AI 帮你纠。说乱了，AI 帮你理。

我每天用虾BB输入的文字已经比打字还多了。150字/分钟 vs 40字/分钟，这不是优化，这是代际变革。

[画面：GitHub 页面]

**Z**: 这个工具完全免费，开源，MIT 协议。280KB。

[画面：终端一行命令]

```
git clone https://github.com/dyz2102/clawbb
```

**Z**: 如果你试了觉得好用——给个 Star。如果发现 bug——开个 Issue。如果你有想法——提个 PR。

[画面：Z 最后的笑]

**Z**: 我是Z，这是虾BB。我们下期见。

如果你也是一个喜欢瞎BB的人——这个工具就是为你做的。

[片尾：虾BB logo + GitHub + 各平台关注]

---

## 后期制作备注

**节奏**: 前3分钟快节奏（hook + 痛点 + insight），中间放慢（踩坑故事），结尾再提速（CTA）

**BGM**: Lo-fi beats，不要太吵，码代码的时候可以稍微加节奏感

**字幕**: 关键数据和对比表用大字幕卡片，不要只靠嘴说

**屏幕录制**: 编译和代码生成部分 4x 加速，只在关键时刻放慢

**B-roll**: 香港夜景延时、键盘特写、Globe 键特写、HUD 动画

**时间轴**:

| 段落 | 时长 | 累计 |
|------|------|------|
| Hook | 0:10 | 0:10 |
| 为什么做 | 1:20 | 1:30 |
| LLM vs ASR insight | 1:30 | 3:00 |
| 一晚上怎么做的 | 4:00 | 7:00 |
| 踩坑记录 | 3:00 | 10:00 |
| 产品打磨 | 1:30 | 11:30 |
| 国内替代 | 1:00 | 12:30 |
| 结尾 | 1:00 | 13:30 |

**关键数据（视频中要展示的）**:
- 语音 150 wpm vs 打字 40 wpm = 3.75x
- 搜狗日均语音使用 20 亿次
- 2028年 AI 交互以语音为主（伦敦政经研究）
- 全球语音识别市场：$4.5B (2024) → $21B (2030)
- 71% 消费者偏好语音而非打字（PwC）
- Gemini 单次最长 9.5 小时音频
- ClawBB: 280KB, 250次/天免费, 0 依赖

---

## 各平台发布标题参考

**YouTube/B站**: "我用AI一晚上做了个比Siri好用的语音输入工具，然后开源了"
**小红书**: "告别Siri！这个280KB的免费语音输入神器，中英混说完美识别"
**Twitter**: "Built a free macOS voice-to-text app in one night. 280KB. Zero deps. Bilingual. Open source. Thread 🧵"
**知乎**: "一个晚上，用AI做了一个比Siri好用的免费语音输入工具"
**微信**: "虾BB：我用AI一晚上做了个免费语音输入神器，然后开源了"
**抖音**: "Siri听不懂中英混说？我一晚上做了个280KB的替代品"


path: content/video-script-v2.md
content: # 虾BB — 视频脚本 V2

> **标题**: "我用 Kimi 在30分钟内 Vibe Code 了一个免费语音输入法，吊打 Typeless"
> **备选**: "微信语音转文字 vs 虾BB：Vibe Coder 的语音输入终极对决"
> **时长**: 8-10分钟
> **形式**: 录屏 + 右下角人脸（OBS/CleanShot），纯电脑前，无外景
> **目标人群**: Vibe Coders — 用 Claude Code / Cursor / Kimi Code 写代码的开发者
> **核心卖点**: 免费、中英混搭、为 Vibe Coding 而生

---

## HOOK — 0:00-0:30

[录屏：干净桌面，打开 Claude Code / Cursor]

**Z（右下角人脸）**: 如果你在 Vibe Coding，你每天说的话比打的字多——

[录屏：按住 Globe 键，HUD 出现在光标旁边]

**Z（对着麦克风说）**: "帮我写一个 API endpoint，支持 async error handling，返回 JSON，加上 rate limiting"

[松开，文字完美出现在 Cursor/Claude Code 的输入框里]

**Z**: 两秒。中英混排。零错误。免费。

[切：大字幕]

> **虾BB — by Vibe Coders, for Vibe Coders**

**Z**: 而且这个工具本身，就是用 Vibe Coding 做出来的。今天我现场演示——用 Kimi Code，30分钟，从零做一个能用的语音输入法。

---

## 竞品对比 — 0:30-2:30

[录屏：分屏对比]

**Z**: 先看看现在的选择有多烂。

### 微信语音转文字（30秒）

[录屏：在电脑端微信打开聊天框，按住语音按钮]

**Z**: 微信自带的语音转文字，很多人在用。我来测一下。

[说]: "帮我用 Claude Code 写一个 React component，要支持 dark mode 和 i18n"

[展示结果：乱七八糟，Claude 变 Cloud，React 没大写，i18n 变成一串数字]

**Z**: Claude 变 Cloud，i18n 变成"一爱恩"。它听不懂开发者在说什么。

### Typeless（30秒）

[录屏：打开 Typeless]

**Z**: Typeless，$12 一个月。

[说同样的话，展示结果]

**Z**: 好一些，但是——$12 一个月。而且它用的是 Whisper，ASR 模型，只听不懂。标点和中英切换还是有问题。

### 虾BB（30秒）

[录屏：按 Globe 键，说同样的话]

**Z**: 同样的话，虾BB。

[展示结果：完美——Claude Code、React、dark mode、i18n 全部正确]

**Z**: 完美。因为虾BB 用的不是 Whisper，是 Gemini——一个大语言模型。它不是在"听写"，它是在"理解"你说什么。

[画面：对比表]

| | 微信语音 | Typeless | 虾BB |
|---|---|---|---|
| 价格 | 免费 | $12/月 | **免费** |
| 中英混搭 | 差 | 一般 | **完美** |
| 开发者术语 | 不行 | 一般 | **精准** |
| 引擎 | ASR | Whisper | **Gemini LLM** |
| 体积 | 微信里 | ~200MB | **280KB** |
| 开源 | 否 | 否 | **MIT** |

---

## 关键 Insight — 2:30-3:30

[录屏：画一个简单示意图，或者用 Excalidraw]

**Z**: 这是整个视频最重要的一个点。

> **Whisper = 听写机器人，只听不懂**
> **Gemini = 一个懂编程的人在帮你记录**

**Z**: Whisper 是 ASR——自动语音识别。它做的是声波到文字的机械转换。所以 Claude 会变成 Cloud，OpenClaw 变成 Open Cloud。

Gemini 是 LLM——大语言模型。它理解 Claude Code 是一个编码工具，它知道 React 和 async 是什么。所以标点完美，术语精准，中英切换自然。

而且虾BB 有一个自定义词库——40+ AI 工具名称预置。你说 "Cursor"，它不会给你 "课色"。你说 "OpenClaw"，它不会变成 "Open Cloud"。

---

## 现场挑战：30分钟 Vibe Code 一个语音输入法 — 3:30-8:00

[录屏：打开终端，全屏]

**Z**: 好，接下来是重头戏。我现在用 Kimi Code，从零开始，30分钟内做一个能跑的语音输入工具。计时开始。

[画面：右上角放一个计时器]

### 第一步：打开 Kimi Code（1分钟）

[录屏：终端输入 kimi]

**Z**: Kimi Code 是月之暗面做的，免费，体验跟 Claude Code 一样。国内直接用。

### 第二步：描述需求（2分钟）

[录屏：输入 prompt]

**Z**: 我给它一个详细的 prompt——

> "用纯 Swift 做一个 macOS 命令行语音转文字工具。功能：按 Enter 开始录音，再按 Enter 停止，用 Google Gemini REST API 转录，输出文字到终端。用 AVAudioEngine 录音，录成 WAV。API key 从环境变量 GEMINI_API_KEY 读取。"

[Kimi 开始写代码，4x 加速]

**Z**: 看它写...

### 第三步：编译调试（5分钟，加速播放）

[录屏：swiftc 编译，报错，贴给 Kimi，修复，再编译]

**Z**: 第一次编译——报错。正常。贴给 Kimi。再来。

[来回几次后编译通过]

**Z**: 通了！

### 第四步：第一次测试（2分钟）

[录屏：运行程序，按 Enter，说话，按 Enter，等待结果]

**Z**: 好，跑一下。

[说]: "这是一个用 Kimi Code 在终端里做出来的语音转文字工具"

[结果出来，基本正确但可能有小问题]

**Z**: 能用了！但是——

### 第五步：加 Globe 键支持（5分钟，加速）

**Z**: 现在它只能按 Enter 录音。我要加 Globe 键支持——按住录音，松开停止。

[给 Kimi 新的 prompt，加 CGEventTap 支持]

[编译调试过程，加速]

### 第六步：最终测试（2分钟）

[录屏：按住 Globe，说一段中英混搭的话]

**Z**: 好，最终版本。

[说]: "帮我查一下 OpenClaw 最新的 skills 列表，然后用 Claude Code 写一个 integration test"

[结果完美显示]

**Z**: 完美。

[计时器停止]

**Z**: XX分钟。从零到一个能用的语音输入工具。这就是 Vibe Coding。

---

## 但是——虾BB 远不止这些 — 8:00-9:00

[录屏：展示完整版虾BB]

**Z**: 刚才做的是一个最简版本。正式的虾BB 还有这些：

[快速展示，每个3-5秒]

1. **实时流式预览** — 说话的同时文字就在出现
   [展示 HUD 实时显示文字]

2. **HUD 跟随光标** — 在哪打字，HUD 就出现在哪
   [展示 HUD 在不同位置出现]

3. **智能词库** — 40+ AI 工具名称预置，支持自定义
   [展示词库页面]

4. **皮肤系统** — 龙虾红、海洋蓝、日落橙、清新绿
   [快速切换几个皮肤]

5. **280KB，零依赖** — 整个 app 比一张截图还小
   [展示文件大小]

---

## CTA — 9:00-10:00

[录屏：GitHub 页面]

**Z**: 虾BB，完全免费，开源，MIT 协议。

[展示下载/安装流程]

**Z**: 下载安装只需要30秒。打开 DMG，拖进 Applications，设置一个免费的 Gemini API Key，就能用了。

[录屏：最后一次展示按 Globe 键说话]

**Z**: 如果你是一个 Vibe Coder——你用 Claude Code，你用 Cursor，你用 Kimi Code——你每天跟 AI 说的话比打的字多。那这个工具就是为你做的。

[画面：大字幕]

> **虾BB — 瞎BB**
> **最好的想法，说出来时都像在瞎说。**

**Z**: GitHub Star 一下。链接在简介。

[片尾：xiabb.lol + GitHub 链接 + 各平台二维码]

---

## 拍摄备注

**设备**: Mac Studio 屏幕录制 + 摄像头右下角（OBS 或 CleanShot）
**无需外景**: 全程电脑前
**BGM**: Lo-fi coding beats，轻背景音
**节奏**:
- 0:00-0:30 Hook（快）
- 0:30-2:30 对比（中速，有冲击力）
- 2:30-3:30 Insight（放慢，这是核心）
- 3:30-8:00 挑战（快+加速，节奏感强）
- 8:00-9:00 功能展示（快速切换）
- 9:00-10:00 CTA（诚恳）

**加速处理**: Kimi 写代码、编译调试部分 4-8x 加速，关键时刻放慢
**计时器**: 右上角始终显示，制造紧迫感
**字幕**: 所有 Terminal 输出加大字幕，确保手机端看得清

**提前准备**:
- Kimi Code 安装好（`npm i -g @anthropic/kimi-code` 或官方方式）
- GEMINI_API_KEY 环境变量设好
- 事先跑通一遍确保30分钟能做完
- 微信桌面端、Typeless 都装好用于对比
- OBS 场景切换快捷键设好


path: content/video-script-youtube.md
content: # 虾BB/ClawBB 开发全记录 — 完整视频脚本

---

## 片头 / 开场钩子（0:00 - 0:30）

---

[画面：黑底白字大字幕，配合键盘声音效果]

[屏幕录制：桌面干净的 Mac，右下角浮现一个小小的波形动画 HUD]

**Z（画外音，语速快，像在分享秘密）：**

好，我给你们看个东西。

[屏幕录制：手指按住 Globe 键，HUD 出现，开始说话]

我现在按住这个键，然后说："帮我写一封邮件 to the client，regarding the Q1 report，大概 three paragraphs，professional tone。"

[屏幕录制：松开 Globe 键，文字瞬间出现在光标位置，中英混排，完美无误]

你看到没有？中英混排，一个字没错。

[画面：切到面对镜头，Z 直视镜头]

我昨晚用 AI，做了一个免费的语音输入工具，比 Siri 好用十倍。

[画面：字幕打出]

**虾BB — 因为叫"虾BB"，也就是"瞎BB"**

废话精，那是我。这个工具也是。

我叫 Z，今天带你们从零开始，把这个东西做出来。

---

## 第一部分：我为什么做这个（0:30 - 2:30）

---

[画面：Z 坐在桌前，背后是香港夜景或者简洁的工作台]

好，先说背景。

我在香港做了十年创业，最近这一两年，语音输入对我来讲越来越重要。为什么？因为我每天要输出大量的文字——给团队发指令，写产品需求，回客户消息——手打太慢了。

[画面：演示 Siri 语音输入，输入"帮我写邮件 regarding the Q1 report"]

[屏幕录制：Siri 把 "regarding" 转成了 "regarding" 但前后中文全乱码，结果一团糟]

你看，Siri。Siri 在中英混输这件事上，基本上是废的。它要么全中文模式，要么全英文模式，你一切换，它就不知道你在说什么。

[画面：切换到 Whisper 界面]

然后我试过 Whisper，就是 OpenAI 那个开源语音模型。Whisper 的准确率很高，但它是离线跑的，你要自己搭环境，而且本地跑比较吃资源，速度也慢。

[画面：打开 App Store，找到 Typeless 之类的付费工具]

还有一些付费工具，比如 Typeless，月费十二美金，大概接近一百港币。我不是付不起，我是觉得——等等，这东西 Google 不是有免费 API 吗？

[画面：Z 停顿，做出"等一下"的手势]

对。Google Gemini。

[屏幕录制：打开 Google AI Studio 的页面，显示 Free Tier 字样]

Google 的 Gemini API，有免费额度。而且 Gemini 的多语言能力——特别是中英混排——在我测试下来，是目前所有模型里面最自然的。

它不是强行翻译，它是真的理解你在说什么语言，然后原样转录。

[画面：Z 倾身向前，语气变得兴奋]

所以我那天晚上十二点，突然想到：我他妈为什么要付费？我自己做一个不就完了？

[画面：字幕]

**凌晨 12:00 — 想法出现**
**凌晨 6:00 — 产品完成**

这就是你们今天要学的内容。

---

## 第二部分：架构讲解（2:30 - 5:30）

---

[画面：白板或者简洁的示意图动画，Z 开始讲解]

好，在我们打开 Claude Code 之前，先花三分钟搞清楚这个东西的架构。

[画面：示意图出现，标题"虾BB 系统架构"]

这个工具，我叫它 ClawBB，本质上只做一件事：**你按住一个键说话，松手之后文字出现在光标位置**。

听起来很简单，对吧？但里面有几个技术难点。

[画面：第一个模块高亮"按键检测"]

**第一个难点：怎么检测 Globe 键？**

Globe 键就是 Mac 键盘左下角那个地球仪键，正式名字叫 fn 键的替代品。问题是，这个键不走普通的 NSEvent 系统，你要用 macOS 底层的 CGEventTap 来捕获它。

CGEventTap，直译就是"事件监听器"。它可以在系统级别拦截所有键盘事件，包括 Globe 键。

[画面：代码框出现，显示 CGEventTap 关键词，但是只是示意，不深入]

这玩意儿需要 Accessibility 权限，这后面会讲到，是一个大坑。

[画面：第二个模块高亮"录音"]

**第二个难点：录音和格式转换**

我们用 macOS 原生的 AVAudioEngine 来录音。录音采样率是 48kHz，但 Gemini API 要求的是 16kHz，所以中间要做一个降采样。

48kHz 降到 16kHz，这个转换如果做错了，声音会变形，识别率会暴跌。

[画面：第三个模块高亮"双引擎"]

**第三个难点，也是最有意思的地方：双引擎架构**

[画面：两个路径的示意图，左边"Live API WebSocket"，右边"REST API"]

我们同时跑两个 Gemini API：

第一个，Live API，走 WebSocket，实时流式传输。你一边说，它一边给你预览文字，这样用户不会觉得在等待，体验很流畅。

第二个，REST API，就是普通的 HTTP 请求，等你说完了，把整段录音发过去，拿到最终结果，准确率更高。

最后展示给用户的，是 REST API 的结果，但过渡阶段用 Live API 的预览来做动画效果。

[画面：第四个模块高亮"HUD"]

**第四个：HUD 浮层**

HUD 就是那个漂浮在屏幕上的小窗口，显示波形动画，让你知道"我在录音"。

这个窗口不能抢焦点，不然你的光标位置就丢了，文字就不知道输入到哪里去了。

[画面：技术栈总结出现]

最终技术栈：

- **语言**：纯 Swift，零依赖
- **体积**：280KB 二进制文件
- **API**：Google Gemini（免费额度）
- **核心组件**：CGEventTap + AVAudioEngine + WebSocket + NSPanel HUD

[画面：Z 看向镜头]

好，架构清楚了。现在我们打开 Claude Code，开始写代码。

---

## 第三部分：用 Claude Code 来构建（5:30 - 10:30）

---

[屏幕录制：打开终端，输入 `claude`，Claude Code 界面出现]

[画面：Z 画外音]

我用的是 Claude Code，Anthropic 的官方命令行工具。你可以把它想象成一个住在终端里的高级程序员，你跟它说你要什么，它帮你写代码、改代码、找 bug。

[屏幕录制：新建一个空文件夹 `gemini-voice`]

我先建一个空文件夹，然后进去，打开 Claude Code。

现在关键来了：**第一个 prompt 怎么写？**

很多人做 AI 编程的时候，第一个 prompt 写得太笼统——"帮我做一个语音转文字工具"——结果 AI 不知道你要什么，写出来的东西要么太简单，要么方向完全错了。

[画面：Z 的 prompt 内容以字幕形式显示]

我的第一个 prompt 大概是这样的：

> "我想做一个 macOS 原生 Swift 应用，功能是按住 Globe 键录音，松手后通过 Google Gemini API 语音转文字，结果自动输入到当前光标位置。需要 CGEventTap 检测 Globe 键，AVAudioEngine 录音并转换为 16kHz，同时使用 Gemini Live API WebSocket 做实时预览和 REST API 做最终识别，HUD 窗口显示波形动画，不能抢焦点。用纯 Swift 实现，不用 SwiftUI，零第三方依赖。"

[画面：Z 转向镜头]

你看，我把每一个技术细节都告诉它了。API 选型，录音参数，窗口行为，依赖要求。越具体，AI 写出来的东西越接近你想要的。

[屏幕录制：Claude Code 开始工作，文字快速滚动，代码出现]

然后你就看它开始写。

[屏幕录制：加速播放，显示 main.swift 逐渐形成，行数增加到 1700 多行]

大概十分钟不到，一个 1700 行的 main.swift 出现了。

[画面：Z 语气里带着一点戏谑]

1700 行。一个人可能要写一周。

当然，1700 行不代表它能跑，对吧？接下来就是调试。

[画面：示意图，显示"开发循环"]

**AI 辅助开发的核心循环就四步：**

构建 → 测试 → 汇报问题 → AI 修复 → 回到构建

[屏幕录制：运行 `swiftc main.swift -o xiabb`，出现编译错误]

第一次编译，报错。正常。

我直接把错误信息复制粘贴给 Claude Code：

> "编译报错如下：[粘贴错误]，帮我修复"

[屏幕录制：Claude Code 分析错误，给出修复方案，修改代码]

它找到问题，修了。

再编译。

[屏幕录制：又报错，这次是不同的错误]

又报错。

再粘贴给它。

[画面：Z 笑着说]

就这样来来回回，可能五六次，大部分问题解决了。

但有一个问题，它自己解决不了，因为那不是代码问题，那是 macOS 系统的坑。

[画面：稍作停顿，吊胃口]

这个我们等会说。

[屏幕录制：最终编译成功，显示 "Build successful"，运行程序，HUD 出现在屏幕上]

[画面：Z 面向镜头，表情是那种疲惫但满足的创业者表情]

凌晨大概三点，它能跑了。

不完美，但它能跑了。

---

**Python 到 Swift 的迁移故事**

[画面：Z 做了个"让我岔开说一下"的手势]

对了，我还要跟你们讲一个插曲。

其实我最开始不是用 Swift 写的。我最开始用 Python。

为什么？因为 Python 快啊，原型快，我熟悉，Claude Code 写 Python 也很溜。

[屏幕录制：一个 Python 脚本，调用 sounddevice 录音，调用 Gemini API]

Python 版本大概两百行，一个小时搞定，跑起来了，语音识别也正常。

然后我发现了一个致命问题。

[画面：macOS 权限弹窗截图]

macOS 的麦克风权限，必须要有一个 `.app` bundle，或者有特定签名的可执行文件，才能申请。

Python 脚本是没有资格申请麦克风权限的。

你每次运行 Python 脚本去录音，要么被系统拒绝，要么弹出一个奇奇怪怪的权限请求，用户体验非常差。

[画面：Z 叹气]

所以我跟 Claude Code 说：把这个用 Swift 重写，原生 app 架构，要处理好所有权限。

它用了……差不多两个小时，把整个东西用 Swift 重写了一遍。

[画面：Z 语气带点哲理]

这就是 AI 编程的另一面——它能帮你快速验证想法，但迁移的决策、架构的判断，还是要你自己来。

---

## 第四部分：那些坑和教训（10:30 - 15:30）

---

[画面：Z 靠向椅背，表情变得更放松，像在复盘]

好，现在进入最好玩的部分。

跟你们讲讲那些坑。

因为如果你们自己做类似的项目，这些坑你们大概率都会踩。

---

**坑一：Accessibility 权限地狱**

[屏幕录制：程序运行，但按 Globe 键没有任何反应]

CGEventTap 需要 Accessibility 权限。

你去系统设置里，隐私与安全，辅助功能，加上你的程序。

[屏幕录制：在辅助功能列表里找到程序，勾选]

但问题是，你的程序是从终端运行的一个二进制文件，macOS 认的是"Terminal"这个程序，不是你的程序。

[画面：Z 做出"所以呢"的手势]

所以你加的不是你的程序的权限，你加的是整个 Terminal 的权限。

这意味着，每次你重新编译、生成新的二进制文件，权限还在，没问题。

但是！如果你换了一个终端，或者用不同路径运行，权限就可能失效。

[画面：字幕，强调]

**解法：在辅助功能里，手动添加 Terminal.app（或者你用的 iTerm2）**

[屏幕录制：System Settings → Privacy & Security → Accessibility，添加 Terminal.app]

对，不是添加你的 binary，是添加 Terminal 本身。

这听起来很蠢，但这就是 macOS 的机制。

---

**坑二：Event Tap 超时**

[屏幕录制：程序运行正常，然后过了几分钟，Globe 键不响应了]

这个坑更隐蔽。

macOS 有一个机制：如果你的 CGEventTap 处理事件太慢，系统会把它 disable 掉。

silently。悄悄地。不告诉你。

你的程序还在跑，没有报错，但 Globe 键已经不响应了。

[画面：Z 模仿系统在背后偷偷关掉你的监听器]

解法是什么？写一个 watchdog，每隔几秒检查一下 event tap 是否还活着，如果 disabled 了，重新 enable 它。

[画面：字幕]

**CGEventTap + Watchdog Timer，缺一不可**

---

**坑三：Flash-Lite 在翻译，不在转录**

[屏幕录制：用 Gemini Flash-Lite 模型，说了中文，出来的是英文翻译]

我最开始选的模型是 `gemini-flash-lite`，因为它最便宜、延迟最低。

结果我说中文，它给我翻译成英文输出。

[画面：Z 懵了的表情]

我检查了半天代码，以为是 prompt 写错了。

后来发现，是因为 Flash-Lite 的某些配置下，它会自动翻译，而不是转录。

[画面：字幕，模型对比]

**Gemini 模型选型建议：**
- Live API 预览：`gemini-2.0-flash-live` — 低延迟，支持流式
- REST 最终识别：`gemini-2.0-flash` — 准确率更好

而且 prompt 里要明确写：**"转录原文，不要翻译，保持原始语言"**。

这一句话，救了我很多次。

---

**坑四：Code Signing 让 TCC 失忆**

[画面：Z 语气变得有点疲惫]

这个坑，是我在准备把工具分享给朋友的时候踩到的。

TCC 是 macOS 的权限数据库，Transparency, Consent, and Control。所有的麦克风权限、辅助功能权限，都存在这里。

[屏幕录制：重新对程序进行代码签名，然后运行，麦克风权限弹窗又出现了]

每次你对程序重新签名，TCC 会认为这是一个新的程序——因为签名变了——然后把之前的权限全部作废。

用户又要重新授权。

[画面：字幕]

**解法：在开发阶段，不要频繁重签名。发布版本确定后，一次性签名。**

如果你要做一个给别人用的工具，想好发布策略，不要随便改签名。

---

**坑五：API Key 暴露在 URL 里**

[画面：Z 表情严肃了一点]

这个是安全问题，我要认真讲。

我最开始的实现，API Key 是拼在 URL 里的，类似 `https://api.google.com/...?key=YOUR_API_KEY`。

[画面：字幕，一个示意性的 URL，key 暴露在外]

这有什么问题？

问题在于，URL 会出现在系统日志、代理日志、甚至有时候会被浏览器缓存。你的 API Key 有可能在你不知情的情况下泄露。

[画面：Z 做了个"很严重"的手势]

正确的做法是，把 API Key 放在 HTTP Header 里：`x-goog-[REDACTED_SECRET]`。

[画面：字幕]

**API Key 永远放 Header，不放 URL**

Claude Code 在做安全审查的时候，帮我发现了这个问题。

所以，让 AI 帮你做 security audit，这个步骤不要省。

---

## 第五部分：把它做成一个真正的产品（15:30 - 18:30）

---

[画面：Z 重新振作，语气变得轻快]

好，bug 修完了，基本功能跑通了。

但一个能跑的工具，和一个能用的产品，还差着一段距离。

---

**HUD 设计的进化史**

[画面：一系列 HUD 截图，从简单到复杂]

我跟你们说说这个 HUD 的演化。

第一版，就是一个点。一个绿色的点，在屏幕角落。

[画面：绿点截图]

简单，但是没有存在感，用户不知道是在录音还是在等待。

第二版，我让 Claude Code 做了一个圆形图标，带个麦克风符号。

[画面：图标截图]

好一点，但还是太静态。

第三版，波形动画。录音的时候，五条竖线上下跳动，模拟声音波形。

[画面：波形动画截图或 gif]

这个效果我比较满意。动态的，直观的，告诉用户"我在工作"。

[画面：Z 笑着提到]

对了，我中间还让 Claude Code 尝试做了一个龙虾爪形状的图标，毕竟虾BB嘛，要有主题。

[画面：一个略显奇怪的龙虾爪线条图标]

做出来……很抽象。我决定还是用波形。

---

**权限引导窗口**

[屏幕录制：第一次运行程序，出现一个权限说明窗口]

用户第一次运行，需要授权麦克风和辅助功能权限。

如果你只是弹一个系统权限框，用户不知道为什么这个程序要这些权限，可能直接拒绝。

所以我做了一个引导窗口，解释每个权限的用途，然后引导用户去系统设置里开启。

[画面：引导窗口截图，中文说明每个权限的作用]

这个细节，非常影响用户体验。

---

**卸载脚本的安全审计**

[画面：Z 语气变得认真]

我还写了一个安装脚本和卸载脚本。

Claude Code 帮我写完之后，我让它做了一次安全审查：

> "检查这个 uninstall.sh，确认没有危险操作，不会误删用户文件"

[屏幕录制：Claude Code 分析脚本，指出一个 `rm -rf` 路径拼接的潜在问题]

它发现了一个问题：如果某个环境变量为空，`rm -rf` 加上空字符串，可能会变成删除根目录的操作。

[画面：Z 看向镜头]

这种 bug，你自己写的时候很难发现，因为你的环境下变量永远不为空。

但用户的环境千奇百怪。

**让 AI 审查你的 shell 脚本，这个习惯要养成。**

---

**用 Remotion 做宣传视频**

[屏幕录制：一个用代码生成的动画视频，展示产品功能]

最后，我用 Remotion 做了一个产品演示视频。

Remotion 是一个用 React 写视频的工具，你用 JavaScript/TypeScript 写动画逻辑，它渲染成 mp4。

[画面：代码和对应的动画效果并排显示]

这个也是 Claude Code 帮我写的。我描述我想要的动画效果，它给我写出 Remotion 组件。

[画面：Z 语气带点感叹]

你想想，以前做一个产品宣传视频，你要找设计师，找剪辑，可能要花几千块，花一个星期。

现在，我用 AI 写代码，生成视频，整个流程自己搞定。

这就是为什么我觉得，现在是创业最好的时代，也是个人开发者最好的时代。

---

## 结尾（18:30 - 20:00）

---

[画面：Z 面向镜头，最后总结]

好，快速回顾一下。

[画面：时间轴字幕出现]

**凌晨 12:00** — 有了这个想法
**凌晨 12:30** — 第一个 Python 原型跑通
**凌晨 2:00** — 决定迁移到 Swift，Claude Code 开始重写
**凌晨 4:00** — Swift 版本基本跑通，开始修 bug
**凌晨 6:00** — HUD、权限引导、安全审查全部完成

一个晚上。

[画面：Z 停顿，语气变得真诚]

我不是在炫耀。我是想告诉你们：

**这件事，你们也可以做。**

你不需要是全栈工程师。你不需要十年编程经验。

你需要的是：

**一，搞清楚你要解决什么问题。**
**二，能跟 AI 把问题说清楚。**
**三，有不断调试、不放弃的耐心。**

[画面：GitHub 页面，ClawBB 仓库，README 清晰]

这个项目我已经开源了，链接在简介里。

你可以直接拿去用，也可以把它当作一个学习 Swift、学习 CGEventTap、学习 Gemini API 的案例。

[画面：Z 最后一个笑，字幕出现]

虾BB，也就是瞎BB。

最好的想法，在一开始听起来都有点蠢。

什么，你要用一晚上做一个语音输入工具？

是啊，我就是这么瞎BB的。

[画面：产品运行画面，Globe 键按下，波形跳动，文字出现]

如果你喜欢这个视频，记得点赞，收藏，关注。

如果你也在用 AI 做自己的工具，欢迎在评论区分享。

我是 Z，我们下期见。

[片尾画面：虾BB logo + GitHub 链接 + 关注提示]

---

## 后期制作备注

---

**字幕风格：** 中文主字幕，英文技术词保持英文，不翻译（CGEventTap、AVAudioEngine、WebSocket 等）

**BGM 建议：** 开场30秒用较高能量的电子音乐，技术讲解部分换成低调的 lo-fi，结尾回到轻快节奏

**屏幕录制剪辑建议：** 加速到2-4x，只保留关键节点，用 zoom in 强调重要代码行

**B-roll 素材：** 香港夜景、代码滚动特效、macOS 系统弹窗截图、Gemini API 文档页面

**时间轴对照：**

| 段落 | 时长 | 累计 |
|---|---|---|
| 开场钩子 | 0:30 | 0:30 |
| 为什么做这个 | 2:00 | 2:30 |
| 架构讲解 | 3:00 | 5:30 |
| 用 Claude Code 构建 | 5:00 | 10:30 |
| 坑和教训 | 5:00 | 15:30 |
| 产品化打磨 | 3:00 | 18:30 |
| 结尾 | 1:30 | 20:00 |


path: content/wechat-article.md
content: # 虾BB：我用AI一晚上做了个免费语音输入神器，然后开源了

---

## 你有没有遇到过这种情况

你对着Mac说一句话——

"帮我写一封email，subject是 project update，内容说明一下这周的进度。"

然后Siri给你返回的是：

"好的，我帮你在Safari里搜索 'email project update 这周的进度'。"

……

我当时的表情，大概就是那种盯着屏幕三秒钟、深呼吸、然后默默关掉Siri的那种。

---

## 语音输入这件事，一直是个谎言

语音输入这个功能，在科技公司的PPT里出现了至少二十年了。

每次新品发布会，总有人拿出手机说："你看，我只需要说……"然后说出一句完美的普通话，完美地被识别，完美地变成文字。

但我们真实的使用场景是什么？

是中英文混着说，是带着口音，是在嘈杂的咖啡厅里，是说到一半改了主意。

**现有的语音输入工具，基本上是为一种假想用户设计的——那个人说话标准、只讲一种语言、思路永远清晰。**

我不是那种人。

我是一个创业者，脑子里永远有七件事同时在转。我说话中英夹杂，想到哪说到哪。我的工作语言既不是纯中文也不是纯英文，而是一种只有我自己和身边几个人才能听懂的混合体。

我试过Siri——中英混的识别率，呵呵。

我试过Whisper本地跑——延迟高，而且每次都要打开终端，谁受得了。

我试过某几个付费工具——订阅费每月$15，结果识别质量还不如Gemini随手一调。

我的结论是：**要么将就，要么自己做。**

---

## 凌晨一点，灵感来了

那天晚上我在查Google Gemini API的文档，顺手试了一下语音识别的接口。

我发给它一段混着中英文的录音，它干净利落地还给我一段文字，准确率高得让我有点不敢相信。

> 我突然意识到：这玩意是免费的，它能识别中英混杂，它有API。那为什么没有人用它做一个像样的Mac语音输入工具？

答案可能是：因为懒。

或者更准确地说：因为这件事听起来很麻烦。

录音、调API、把文字输出到光标位置——每一步单独拎出来都不算难，但把它们串在一起，还要做成一个好用的小工具，要处理系统权限、要打包成app、要让它在任何输入框里都能工作……

这就是"听起来一晚上能搞定，实际上搞了一周"的那种项目。

我决定试试。

---

## 第一版：Python原型，三小时出来了

先用Python写个原型，验证思路。

逻辑很简单：

1. 监听一个全局快捷键（按住Globe键）
2. 按住时开始录音
3. 松开后把音频发给Gemini API
4. 把返回的文字粘贴到当前光标位置

Python端写起来很快。`sounddevice`录音、`pyperclip`复制粘贴、`pynput`监听按键——三小时原型跑通了。

我说了句"帮我写个todo list，包括buy groceries和回邮件"，它准确地输出了这句话。

我当时高兴得想拍桌子。

然后我尝试把它打包成一个独立的app，分享给朋友用。

然后我遇到了macOS的权限地狱。

---

## macOS权限：一个比老板还难搞的东西

macOS的权限体系，是一套由苹果精心设计的、让开发者痛苦的系统。

麦克风权限、无障碍权限、输入监控权限——每一个单独申请都要在系统偏好设置里手动开启。Python打包出来的app，还要处理代码签名的问题，不签名就会被Gatekeeper拦住，签名要花钱买开发者账号。

更致命的是：Python版本的全局Event Tap（用来监听Globe键）不稳定。macOS在某些情况下会偷偷把Event Tap关掉，完全不通知你，然后快捷键就失灵了。

我调试了大概两天，结论是：**用Python做这件事，是在和macOS掰手腕。**

打包体积也是个问题。一个Python app带上所有依赖，轻轻松松100MB起步。

我想做的是一个轻量小工具，不是一个工程项目。

---

## 转折点：用Swift重写，Claude Code一个文件搞定

我不是Swift程序员。准确地说，我会写Swift，但我写得很慢，而且对macOS系统API不熟。

但我有Claude Code。

我打开Claude Code，把我的需求描述了一遍：全局Globe键监听、录音、Gemini API、文字注入、系统权限请求、一个悬浮HUD显示状态。

然后我就……基本上开始旁观了。

Claude Code开始写Swift。

它处理了`AVFoundation`录音、`CGEventTap`全局按键监听、`AXUIElement`辅助功能API文字注入——这些我自己查文档要查半天的东西，它一气呵成，还顺手加了错误处理和权限检查。

最终的结果是：**一个Swift文件，1700行，覆盖了所有核心功能。**

> 我第一次有一种感觉：AI不是在帮我写代码，而是在替我把一个"脑子里有但手太慢"的东西，直接具现出来。

当然，中间也遇到了坑。

---

## 那些让我一度怀疑人生的坑

**坑一：Event Tap权限丢失**

macOS有一个不成文的规矩：如果你的app没有正确持有辅助功能权限，`CGEventTap`会在某个时机被系统静默禁用。没有报错，没有通知，就是突然不工作了。

解法是加一个保活机制：每隔几秒检查Event Tap是否还活着，死了就重新注册。

**坑二：文字注入的兼容性**

把文字"打"到光标位置，有好几种方式。`NSPasteboard`+模拟粘贴最通用，但有些app会拦截剪贴板操作。`AXUIElement`直接写入最干净，但并不是所有app都支持。

最后做了一个优先级：先试AXUIElement，失败了fallback到粘贴板模拟。

**坑三：API Key放哪里**

第一版我直接把Gemini API Key存在UserDefaults里，明文。

后来想了想，不对，用户的Key不能这么存。改成了macOS Keychain，虽然代码多了几十行，但至少睡得着觉。

**坑四：HUD设计**

第一版的HUD是一个红点。

朋友看了之后说："这是在录音还是在报警？"

然后我换成了一个麦克风图标，加了声波动画，加了识别中的旋转指示器。

再后来，灵光一现，换成了一只小龙虾举着麦克风的图标。

因为这个工具叫虾BB。虾当然要出镜。

---

## 为什么叫"虾BB"

这个名字是凌晨三点想到的。

"虾BB"谐音"瞎BB"。

BB这个字在粤语和北方话里，都有"乱说话"的意思。"瞎BB"就是"乱说一气"。

我觉得这个词完美地描述了语音输入的本质：**你不用想好再说，你直接说，说出来就是输出。**

也完美地描述了我这个创业者的状态：每天满脑子想法，很多都像在瞎说，但偶尔有几句是对的。

> **虾BB的核心哲学：最好的想法，说出来都像在瞎BB。但不说出来，一个都没有。**

所以就叫虾BB了。

---

## 产品本身，说几个我自己最喜欢的设计

**按住Globe键说话，松开文字出现。**

就这一个交互，没有别的。不用打开任何窗口，不用切换任何界面。在任何输入框、任何app里，按住Globe键，说话，松开，文字就出现在光标位置。

这是我认为语音输入应该有的形态：**它应该是透明的，不应该打断你的工作流。**

---

**280KB。**

整个app，280KB。

我手机里随便一张截图都比它大。

这不是我故意追求小体积，而是Swift写的原生app本来就小。没有Electron，没有Python运行时，没有各种框架——就是一个macOS原生app。

---

**每天250次，免费。**

Gemini API有免费额度，每天250次请求。

对于正常使用来说，250次够了。你一天要说超过250段话才能用完，相当于每小时16次，平均每四分钟一次——这已经是非常重度的使用了。

如果真的不够用，可以在Google AI Studio里申请更高的免费额度，或者付费升级。但大多数人用不到。

**我不想做一个收钱的工具。我想做一个让人用得爽的工具。**

---

**HUD的设计哲学。**

悬浮窗口显示三种状态：

- 🦞🎤 按住录音中，声波动画跳动
- ⏳ 识别中，旋转等待
- ✅ 完成，短暂显示再消失

HUD会自动吸附到屏幕边缘，不挡你的工作区。你可以拖动它到任何位置，它记住位置，下次还在那里。

小细节，但用起来顺手的感觉，就来自这些小细节。

---

## 和现有工具比一比

| | 虾BB | Whisper本地 | Typeless | Siri |
|---|---|---|---|---|
| **价格** | 免费 | 免费（但要配环境） | $15/月 | 免费 |
| **中英混合识别** | ✅ 很好 | ⚠️ 一般 | ✅ 还行 | ❌ 差 |
| **延迟** | ~1-2秒 | ~3-5秒 | ~1秒 | ~1秒 |
| **安装难度** | 下载即用 | 需要配Python环境 | 下载即用 | 内置 |
| **任意输入框** | ✅ | ✅ | ✅ | ❌ 仅原生app |
| **体积** | 280KB | 几个GB（模型文件） | 几十MB | 系统内置 |
| **隐私** | API调用（可审计） | 纯本地 | 云端 | 苹果云端 |
| **开源** | ✅ | ✅ | ❌ | ❌ |

选哪个，看你的优先级。

如果你在意隐私、不介意延迟和配置成本，Whisper本地跑是最纯粹的选择。

如果你就想要一个能用、好用、不花钱的工具，那就是虾BB。

---

## 为什么要开源

老实说，一开始我没想开源。

这个工具我自己用着挺顺手的，打包一下、写个下载页、交差了事，多简单。

但后来想了想，这个工具能做成什么样，很大程度上取决于有多少人用、多少人提反馈、多少人贡献。

我想要的功能可能和你想要的不一样。有人可能想要热词替换，有人可能想要多语言切换，有人可能想支持其他API提供商。

如果是闭源的，这些都得我一个人做，我做不过来。

开源之后，**它就不是我一个人的瞎BB了，而是一群人的瞎BB**。

> **一个人的瞎BB是噪音，一群人的瞎BB是产品。**

另外，坦白说：我用Claude Code写的代码，让更多人看到、审计、改进，这本身也是一种对AI辅助开发的验证。

如果有人看完代码之后说"这代码写得真好"——谢谢Claude。

如果有人说"这地方有bug"——欢迎提PR。

---

## 怎么安装

**方法一：下载Release（推荐）**

去GitHub的Releases页面，下载最新的`.dmg`文件，拖进Applications文件夹，打开。

首次打开会要求几个权限：

- 麦克风权限（废话，它要录音）
- 辅助功能权限（用来注入文字到其他app）
- 输入监控权限（用来监听Globe键）

全部允许，然后在设置里填入你的Gemini API Key（免费申请地址：aistudio.google.com），就可以用了。

**方法二：自己编译**

```bash
git clone https://github.com/xiabb/xiabb-app
open xiabb-app/xiaBB.xcodeproj
```

在Xcode里点Build，完事。

需要Xcode 15+，macOS 13+。

---

## 接下来想做什么

产品功能上，我有几个方向在想：

**多API支持。** Gemini免费够用，但有人可能更信任OpenAI的Whisper，或者用国内的讯飞、腾讯。做成可切换的。

**热词词典。** 有些专业词汇或者人名，语音识别老是识错。允许用户自定义替换规则，说"张三"自动替换成"张三丰"这种。

**识别历史。** 有时候你说完一句话，文字出来了，然后你不小心删掉了，不知道说的是什么。加一个本地历史记录，随时可查。

**Windows版。** 有人问了。我不会Windows开发，但如果有人愿意Port过去，完全欢迎。

---

## 写在最后

这个工具从有想法到第一个能用的版本，大概花了一个晚上加第二天下午。

代码主要是Claude Code写的，我主要负责想清楚要做什么、测试哪里不对劲、以及给龙虾的麦克风配色。

我分享这个故事，不是要说"AI多厉害"。

我想说的是：**很多工具不存在，不是因为技术上做不到，而是因为没有人觉得值得花时间去做。**

AI把这个门槛拉低了。现在，你只需要知道你想要什么，然后把它说清楚——就像用虾BB说话一样。

> **如果你心里有个"这东西应该存在但好像没人做"的想法，也许现在是时候瞎BB一下，把它做出来。**

最坏的结果是你做了一个只有自己用的破工具。

但那也挺好的。

---

**如果你也是一个喜欢瞎BB的人，欢迎来给虾BB点个Star。**

如果你用着觉得好用，转发给身边需要语音输入的朋友。

如果你发现了bug，开个Issue。

如果你有想法，提个PR。

我们一起把这个工具BB得更好。

---

## 项目地址

**GitHub：** `github.com/xiabb/xiabb-app`

（在公众号后台回复 **"虾BB"** 获取直达链接）

---

**[二维码占位]**
扫码直达GitHub仓库

---

*— 虾BB，让说话变成生产力*

*本文作者：Z / 深夜写于香港 / 配套工具：Claude Code + 过量咖啡*

---

> **关注「虾BB」，获取更多"瞎BB出来的"产品和工具。**


path: design-versions/v1-white-blue/main.swift
content: // XiaBB (虾BB) — Native macOS Voice-to-Text powered by Google Gemini
// Hold Globe (fn) key to speak. Real-time preview + accurate final transcription.
// Pure Swift, no external dependencies.

import AppKit
import AVFoundation
import CoreGraphics
import Foundation
import WebKit

// MARK: - Debug Logging

let logFileURL: URL = {
    let url = URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Logs/XiaBB.log")
    // Truncate on launch (keep last log only)
    try? "".write(to: url, atomically: true, encoding: .utf8)
    return url
}()

func log(_ msg: String) {
    let ts = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium)
    let line = "[\(ts)] \(msg)\n"
    fputs(line, stderr)
    // Also write to log file for debugging when launched via Finder
    if let fh = try? FileHandle(forWritingTo: logFileURL) {
        fh.seekToEndOfFile()
        fh.write(line.data(using: .utf8)!)
        fh.closeFile()
    }
}

// MARK: - Constants

let APP_NAME = "XiaBB"
let APP_ID = "com.xiabb"
let SAMPLE_RATE: Double = 16000
let CHANNELS: UInt32 = 1
let DAILY_FREE_LIMIT = 250 // Gemini 2.5 Flash free tier: 250 RPD
let MODEL_REST = ProcessInfo.processInfo.environment["XIABB_MODEL"] ?? "gemini-2.5-flash"
let MODEL_LIVE = "gemini-2.5-flash-native-audio-latest"
let FN_FLAG: UInt64 = 0x800000 // NSEventModifierFlagFunction

let WS_URL_BASE = "wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent"

let BASE_PROMPT = """
Transcribe this audio exactly as spoken, with proper punctuation.
- ALL Chinese MUST be Simplified (简体中文). Never output Traditional Chinese.
- Preserve original language per word (Chinese stays Chinese, English stays English).
- Do NOT translate. Output as a SINGLE paragraph. No line breaks.
- Output ONLY the transcribed text.
"""

// MARK: - Custom Dictionary

let dictionaryFile = URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Tools/xiabb/.dictionary.json")

func loadDictionary() -> [String] {
    guard let data = try? Data(contentsOf: dictionaryFile),
          let arr = try? JSONSerialization.jsonObject(with: data) as? [String] else {
        // Default dictionary for Vibe Coding
        return ["Claude", "Claude Code", "OpenClaw", "Cursor", "Gemini", "Kimi", "DeepSeek",
                "Copilot", "ChatGPT", "Midjourney", "Perplexity",
                "VS Code", "Xcode", "GitHub", "GitLab", "Docker", "npm", "Homebrew", "Terminal", "iTerm",
                "API", "async", "await", "React", "Vue", "Python", "TypeScript", "JavaScript",
                "frontend", "backend", "deploy", "localhost", "webhook",
                "meeting", "deadline", "action items", "feedback", "standup", "sprint",
                "pull request", "code review", "Vibe Coding", "AI Agent"]
    }
    return arr
}

func saveDictionary(_ words: [String]) {
    if let data = try? JSONSerialization.data(withJSONObject: words) {
        try? data.write(to: dictionaryFile)
    }
}

func buildPrompt() -> String {
    let words = loadDictionary()
    if words.isEmpty { return BASE_PROMPT }
    let wordList = words.map { "\"\($0)\"" }.joined(separator: ", ")
    return BASE_PROMPT + "\n- IMPORTANT: The following words/names must be transcribed EXACTLY as specified (case-sensitive): \(wordList). Do NOT substitute similar-sounding words."
}

// MARK: - Paths

let scriptDir: URL = {
    // Resources dir in .app bundle, fallback to ~/Tools/xiabb/
    if let resourcePath = Bundle.main.resourceURL {
        return resourcePath
    }
    return URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Tools/xiabb")
}()

let dataDir: URL = {
    // Config/usage data directory
    return URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Tools/xiabb")
}()

// MARK: - API Key

func loadAPIKey() -> String {
    if let envKey = ProcessInfo.processInfo.environment["GEMINI_API_KEY"], !envKey.isEmpty {
        return envKey
    }
    let keyFile = dataDir.appendingPathComponent(".api-key")
    if let data = try? String(contentsOf: keyFile, encoding: .utf8) {
        return data.trimmingCharacters(in: .whitespacesAndNewlines)
    }
    return ""
}

var [REDACTED_SECRET])

// MARK: - T2S (Traditional → Simplified Chinese)

func t2s(_ text: String) -> String {
    let mutable = NSMutableString(string: text)
    CFStringTransform(mutable, nil, "Traditional-Simplified" as CFString, false)
    return mutable as String
}

// MARK: - Config

let configFile = dataDir.appendingPathComponent(".config.json")

func loadConfig() -> [String: Any] {
    guard let data = try? Data(contentsOf: configFile),
          let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
        return [:]
    }
    return json
}

func saveConfig(_ updates: [String: Any]) {
    var existing = loadConfig()
    for (k, v) in updates { existing[k] = v }
    if let data = try? JSONSerialization.data(withJSONObject: existing) {
        try? data.write(to: configFile)
    }
}

// MARK: - Themes / Skins

struct HUDTheme {
    let name: String
    let bgRed: CGFloat, bgGreen: CGFloat, bgBlue: CGFloat, bgAlpha: CGFloat
    let cornerRadius: CGFloat
    let textColor: NSColor
    let recordingColor: NSColor
    let successColor: NSColor
    let barColor: NSColor  // wave bar color during recording
    let fontSize: CGFloat
}

// Theme registry — designed for easy extension. Add new themes here.
// Future: load from .cbskin files (ZIP with theme.json + assets)
let themeList: [(id: String, theme: HUDTheme)] = [
    ("lobster", HUDTheme(
        name: "🦞 Lobster Red",
        bgRed: 0.12, bgGreen: 0.06, bgBlue: 0.06, bgAlpha: 0.93,
        cornerRadius: 12,
        textColor: .white,
        recordingColor: NSColor(calibratedRed: 0.94, green: 0.27, blue: 0.27, alpha: 1),
        successColor: NSColor(calibratedRed: 0.2, green: 0.83, blue: 0.45, alpha: 1),
        barColor: NSColor(calibratedRed: 0.94, green: 0.27, blue: 0.27, alpha: 1),
        fontSize: 13
    )),
    ("chill", HUDTheme(
        name: "🌿 Chill Green",
        bgRed: 0.06, bgGreen: 0.12, bgBlue: 0.08, bgAlpha: 0.92,
        cornerRadius: 16,
        textColor: NSColor(calibratedRed: 0.85, green: 1.0, blue: 0.9, alpha: 1),
        recordingColor: NSColor(calibratedRed: 0.3, green: 0.85, blue: 0.5, alpha: 1),
        successColor: NSColor(calibratedRed: 0.5, green: 1.0, blue: 0.7, alpha: 1),
        barColor: NSColor(calibratedRed: 0.3, green: 0.85, blue: 0.5, alpha: 1),
        fontSize: 13
    )),
    ("ocean", HUDTheme(
        name: "🌊 Ocean Blue",
        bgRed: 0.04, bgGreen: 0.08, bgBlue: 0.18, bgAlpha: 0.93,
        cornerRadius: 14,
        textColor: NSColor(calibratedRed: 0.75, green: 0.92, blue: 1.0, alpha: 1),
        recordingColor: NSColor(calibratedRed: 0.25, green: 0.65, blue: 1.0, alpha: 1),
        successColor: NSColor(calibratedRed: 0.3, green: 1.0, blue: 0.75, alpha: 1),
        barColor: NSColor(calibratedRed: 0.25, green: 0.65, blue: 1.0, alpha: 1),
        fontSize: 13
    )),
    ("sunset", HUDTheme(
        name: "🌅 Sunset Orange",
        bgRed: 0.16, bgGreen: 0.08, bgBlue: 0.04, bgAlpha: 0.93,
        cornerRadius: 14,
        textColor: NSColor(calibratedRed: 1.0, green: 0.93, blue: 0.82, alpha: 1),
        recordingColor: NSColor(calibratedRed: 1.0, green: 0.55, blue: 0.15, alpha: 1),
        successColor: NSColor(calibratedRed: 0.4, green: 0.9, blue: 0.5, alpha: 1),
        barColor: NSColor(calibratedRed: 1.0, green: 0.55, blue: 0.15, alpha: 1),
        fontSize: 13
    )),
]

let themes: [String: HUDTheme] = Dictionary(uniqueKeysWithValues: themeList.map { ($0.id, $0.theme) })

var currentTheme: HUDTheme = {
    let name = loadConfig()["theme"] as? String ?? "lobster"
    return themes[name] ?? themes["lobster"]!
}()

// MARK: - i18n

let strings: [String: [String: String]] = [
    "en": [
        "idle": "Idle",
        "recording": "Recording...",
        "transcribing": "Transcribing...",
        "listening": "Listening...",
        "finalizing": "Finalizing...",
        "copied": "Copied!",
        "left": "left",
        "start": "Start Recording",
        "stop": "Stop",
        "hotkey": "Hold Globe key to record",
        "configure_api": "Configure Gemini API Key...",
        "launch_login": "Launch at Login",
        "language": "Language",
        "quit": "Quit XiaBB",
        "daily_limit": "Daily limit reached",
    ],
    "zh": [
        "idle": "待命",
        "recording": "录音中...",
        "transcribing": "识别中...",
        "listening": "聆听中...",
        "finalizing": "处理中...",
        "copied": "已复制!",
        "left": "剩余",
        "start": "开始录音",
        "stop": "停止",
        "hotkey": "按住 Globe 键录音",
        "configure_api": "配置 Gemini API Key...",
        "launch_login": "开机自动启动",
        "language": "语言 / Language",
        "quit": "退出 虾BB",
        "daily_limit": "今日额度已用完",
    ],
]

var currentLang: String = loadConfig()["lang"] as? String ?? "zh"

func L(_ key: String) -> String {
    return strings[currentLang]?[key] ?? strings["en"]?[key] ?? key
}

// MARK: - Usage Tracker

class UsageTracker {
    private let file = dataDir.appendingPathComponent(".usage.json")
    private var date: String
    private(set) var count: Int

    init() {
        let today = Self.today()
        if let data = try? Data(contentsOf: file),
           let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
           let d = json["date"] as? String, d == today,
           let c = json["count"] as? Int {
            date = d
            count = c
        } else {
            date = today
            count = 0
        }
    }

    static func today() -> String {
        let df = DateFormatter()
        df.dateFormat = "yyyy-MM-dd"
        return df.string(from: Date())
    }

    var remaining: Int { max(0, DAILY_FREE_LIMIT - count) }

    func statusLine() -> String { "\(count)/\(DAILY_FREE_LIMIT) (\(remaining) left)" }

    @discardableResult
    func increment() -> Int {
        let today = Self.today()
        if date != today { date = today; count = 0 }
        count += 1
        save()
        return count
    }

    private func save() {
        let json: [String: Any] = ["date": date, "count": count]
        if let data = try? JSONSerialization.data(withJSONObject: json) {
            try? data.write(to: file)
        }
    }
}

let usage = UsageTracker()

// MARK: - Sound Effects

func generateTone(frequencies: [(Double, Double)], duration: Double, attack: Double = 0.008, release: Double = 0.04, amplitude: Double = 0.05) -> Data {
    let sampleRate = 44100.0
    let numSamples = Int(sampleRate * duration)
    var samples = [Int16](repeating: 0, count: numSamples)

    for i in 0..<numSamples {
        let t = Double(i) / sampleRate
        var value = 0.0
        for (freq, amp) in frequencies {
            value += amp * sin(2.0 * .pi * freq * t)
        }
        // Envelope
        let attackSamples = Int(sampleRate * attack)
        let releaseSamples = Int(sampleRate * release)
        var env = 1.0
        if i < attackSamples {
            env = 0.5 * (1.0 - cos(.pi * Double(i) / Double(attackSamples)))
        } else if i > numSamples - releaseSamples {
            let ri = i - (numSamples - releaseSamples)
            env = 0.5 * (1.0 + cos(.pi * Double(ri) / Double(releaseSamples)))
        }
        samples[i] = Int16(clamping: Int(value * env * amplitude * 32767.0))
    }

    return makeWAVData(samples: samples, sampleRate: Int(sampleRate))
}

func makeWAVData(samples: [Int16], sampleRate: Int, channels: Int = 1) -> Data {
    var data = Data()
    let dataSize = samples.count * 2
    let fileSize = 36 + dataSize

    // RIFF header
    data.append(contentsOf: "RIFF".utf8)
    data.append(contentsOf: withUnsafeBytes(of: UInt32(fileSize).littleEndian) { Array($0) })
    data.append(contentsOf: "WAVE".utf8)

    // fmt chunk
    data.append(contentsOf: "fmt ".utf8)
    data.append(contentsOf: withUnsafeBytes(of: UInt32(16).littleEndian) { Array($0) })
    data.append(contentsOf: withUnsafeBytes(of: UInt16(1).littleEndian) { Array($0) }) // PCM
    data.append(contentsOf: withUnsafeBytes(of: UInt16(channels).littleEndian) { Array($0) })
    data.append(contentsOf: withUnsafeBytes(of: UInt32(sampleRate).littleEndian) { Array($0) })
    data.append(contentsOf: withUnsafeBytes(of: UInt32(sampleRate * channels * 2).littleEndian) { Array($0) })
    data.append(contentsOf: withUnsafeBytes(of: UInt16(channels * 2).littleEndian) { Array($0) })
    data.append(contentsOf: withUnsafeBytes(of: UInt16(16).littleEndian) { Array($0) }) // bits per sample

    // data chunk
    data.append(contentsOf: "data".utf8)
    data.append(contentsOf: withUnsafeBytes(of: UInt32(dataSize).littleEndian) { Array($0) })
    for s in samples {
        data.append(contentsOf: withUnsafeBytes(of: s.littleEndian) { Array($0) })
    }
    return data
}

func sfxStart() -> Data {
    // Two ascending tones (D5 → A5)
    let tone1 = generateTone(frequencies: [(587, 1.0), (1174, 0.2)], duration: 0.07, amplitude: 0.05)
    let tone2 = generateTone(frequencies: [(880, 1.0), (1760, 0.2)], duration: 0.07, amplitude: 0.06)
    // Combine with gap
    let sr = 44100
    let gap = [Int16](repeating: 0, count: Int(Double(sr) * 0.04))
    // Extract PCM from tone1 and tone2, combine, re-wrap
    return combineSounds([tone1, makeWAVData(samples: gap, sampleRate: sr), tone2])
}

func sfxStop() -> Data {
    generateTone(frequencies: [(880, 0.7), (698, 0.5)], duration: 0.12, amplitude: 0.05)
}

func sfxDone() -> Data {
    let tone1 = generateTone(frequencies: [(784, 1.0), (2352, 0.15)], duration: 0.10, amplitude: 0.05)
    let tone2 = generateTone(frequencies: [(1047, 1.0), (3141, 0.12)], duration: 0.18, amplitude: 0.06)
    return combineSounds([tone1, tone2])
}

func sfxError() -> Data {
    let sr = 44100
    let tone = generateTone(frequencies: [(330, 1.0)], duration: 0.08, amplitude: 0.05)
    let gap = [Int16](repeating: 0, count: Int(Double(sr) * 0.06))
    return combineSounds([tone, makeWAVData(samples: gap, sampleRate: sr), tone])
}

func combineSounds(_ wavDatas: [Data]) -> Data {
    // Extract PCM from each WAV, concatenate, re-wrap
    var allSamples = [Int16]()
    for wav in wavDatas {
        // Skip 44-byte WAV header
        if wav.count > 44 {
            let pcmData = wav.subdata(in: 44..<wav.count)
            pcmData.withUnsafeBytes { ptr in
                let bound = ptr.bindMemory(to: Int16.self)
                allSamples.append(contentsOf: bound)
            }
        }
    }
    return makeWAVData(samples: allSamples, sampleRate: 44100)
}

var currentSound: NSSound?

func playSound(_ wavData: Data) {
    DispatchQueue.global(qos: .userInteractive).async {
        let sound = NSSound(data: wavData)
        sound?.play()
        currentSound = sound // prevent dealloc
    }
}

// MARK: - Audio Recording

class AudioRecorder {
    private var engine: AVAudioEngine?
    private var converter: AVAudioConverter?
    private(set) var frames: [Data] = []
    private let lock = NSLock()
    var isRecording = false
    private var configObserver: NSObjectProtocol?

    func start() {
        guard !isRecording else { return }
        frames = []
        isRecording = true

        let engine = AVAudioEngine()
        self.engine = engine

        // Watch for audio config changes (mic disconnected/changed)
        configObserver = NotificationCenter.default.addObserver(
            forName: .AVAudioEngineConfigurationChange, object: engine, queue: nil
        ) { [weak self] _ in
            log("⚠️ Audio config changed (mic disconnected?)")
            guard let self = self, self.isRecording else { return }
            // Try to restart engine with new config
            do {
                try self.engine?.start()
                log("  Audio engine restarted after config change")
            } catch {
                log("  ❌ Audio engine restart failed: \(error)")
                self.isRecording = false
            }
        }

        let inputNode = engine.inputNode
        let hwFormat = inputNode.outputFormat(forBus: 0)

        // Validate hardware format
        guard hwFormat.sampleRate > 0 && hwFormat.channelCount > 0 else {
            log("❌ Invalid audio input format (no mic?): \(hwFormat)")
            isRecording = false
            cleanup()
            return
        }

        guard let targetFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: SAMPLE_RATE, channels: 1, interleaved: true) else {
            log("❌ Cannot create target audio format")
            isRecording = false
            cleanup()
            return
        }

        guard let converter = AVAudioConverter(from: hwFormat, to: targetFormat) else {
            log("❌ Cannot create audio converter")
            isRecording = false
            cleanup()
            return
        }
        self.converter = converter

        let chunkSize: AVAudioFrameCount = AVAudioFrameCount(hwFormat.sampleRate * 0.1)

        inputNode.installTap(onBus: 0, bufferSize: chunkSize, format: hwFormat) { [weak self] buffer, _ in
            guard let self = self, self.isRecording else { return }

            let ratio = SAMPLE_RATE / hwFormat.sampleRate
            let outputFrameCount = AVAudioFrameCount(Double(buffer.frameLength) * ratio)
            guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outputFrameCount) else { return }

            var error: NSError?
            let status = converter.convert(to: outputBuffer, error: &error) { inNumPackets, outStatus in
                outStatus.pointee = .haveData
                return buffer
            }

            if let error = error {
                log("⚠️ Audio convert error: \(error.localizedDescription)")
                return
            }

            if status == .haveData || status == .endOfStream, outputBuffer.frameLength > 0 {
                let byteCount = Int(outputBuffer.frameLength) * Int(targetFormat.streamDescription.pointee.mBytesPerFrame)
                let data = Data(bytes: outputBuffer.int16ChannelData![0], count: byteCount)
                self.lock.lock()
                self.frames.append(data)
                self.lock.unlock()
            }
        }

        engine.prepare()
        do {
            try engine.start()
            log("🎙 Audio engine started (hw: \(hwFormat.sampleRate)Hz → 16000Hz)")
        } catch {
            log("❌ Audio engine start failed: \(error)")
            isRecording = false
            cleanup()
        }
    }

    private func cleanup() {
        if let o = configObserver { NotificationCenter.default.removeObserver(o) }
        configObserver = nil
    }

    func stop() -> [Data] {
        isRecording = false
        engine?.inputNode.removeTap(onBus: 0)
        engine?.stop()
        engine = nil
        converter = nil
        cleanup()
        lock.lock()
        let result = frames
        lock.unlock()
        print("🎙 Recording stopped (\(result.count) chunks)")
        return result
    }

    func getFramesSoFar() -> [Data] {
        lock.lock()
        let result = frames
        lock.unlock()
        return result
    }
}

// MARK: - WAV Encoder (for Gemini API)

func encodeToWAV(frames: [Data]) -> (Data, Double) {
    var allBytes = Data()
    for f in frames { allBytes.append(f) }
    let numSamples = allBytes.count / 2
    let duration = Double(numSamples) / SAMPLE_RATE

    var wav = Data()
    let dataSize = allBytes.count
    let fileSize = 36 + dataSize

    wav.append(contentsOf: "RIFF".utf8)
    wav.append(contentsOf: withUnsafeBytes(of: UInt32(fileSize).littleEndian) { Array($0) })
    wav.append(contentsOf: "WAVE".utf8)
    wav.append(contentsOf: "fmt ".utf8)
    wav.append(contentsOf: withUnsafeBytes(of: UInt32(16).littleEndian) { Array($0) })
    wav.append(contentsOf: withUnsafeBytes(of: UInt16(1).littleEndian) { Array($0) })
    wav.append(contentsOf: withUnsafeBytes(of: UInt16(1).littleEndian) { Array($0) })
    wav.append(contentsOf: withUnsafeBytes(of: UInt32(Int(SAMPLE_RATE)).littleEndian) { Array($0) })
    wav.append(contentsOf: withUnsafeBytes(of: UInt32(Int(SAMPLE_RATE) * 2).littleEndian) { Array($0) })
    wav.append(contentsOf: withUnsafeBytes(of: UInt16(2).littleEndian) { Array($0) })
    wav.append(contentsOf: withUnsafeBytes(of: UInt16(16).littleEndian) { Array($0) })
    wav.append(contentsOf: "data".utf8)
    wav.append(contentsOf: withUnsafeBytes(of: UInt32(dataSize).littleEndian) { Array($0) })
    wav.append(allBytes)

    return (wav, duration)
}

// MARK: - Gemini REST API

func transcribeREST(wavData: Data, completion: @escaping (Result<String, Error>) -> Void) {
    let b64 = wavData.base64EncodedString()
    guard let url = URL(string: "https://generativelanguage.googleapis.com/v1beta/models/\(MODEL_REST):generateContent") else {
        completion(.failure(NSError(domain: "XiaBB", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"])))
        return
    }

    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.setValue(apiKey, forHTTPHeaderField: "x-goog-api-key")
    request.timeoutInterval = 30

    let body: [String: Any] = [
        "contents": [["parts": [
            ["text": buildPrompt()],
            ["inline_data": ["mime_type": "audio/wav", "data": b64]]
        ]]],
        "generationConfig": ["temperature": 0.0, "maxOutputTokens": 4096]
    ]

    request.httpBody = try? JSONSerialization.data(withJSONObject: body)

    URLSession.shared.dataTask(with: request) { data, response, error in
        if let error = error {
            log("[REST] Network error: \(error)")
            completion(.failure(error))
            return
        }
        guard let data = data else {
            completion(.failure(NSError(domain: "XiaBB", code: -1, userInfo: [NSLocalizedDescriptionKey: "No data"])))
            return
        }

        // Log the raw response for debugging
        if let httpResp = response as? HTTPURLResponse {
            log("[REST] HTTP \(httpResp.statusCode), \(data.count) bytes")
        }

        do {
            guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
                let raw = String(data: data.prefix(500), encoding: .utf8) ?? "binary"
                log("[REST] Not a JSON object: \(raw)")
                completion(.failure(NSError(domain: "XiaBB", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid response"])))
                return
            }

            // Check for error response
            if let error = json["error"] as? [String: Any],
               let message = error["message"] as? String {
                log("[REST] API error: \(message)")
                completion(.failure(NSError(domain: "Gemini", code: -1, userInfo: [NSLocalizedDescriptionKey: message])))
                return
            }

            // Parse candidates
            guard let candidates = json["candidates"] as? [[String: Any]],
                  let content = candidates.first?["content"] as? [String: Any],
                  let parts = content["parts"] as? [[String: Any]],
                  let text = parts.first?["text"] as? String else {
                // Check if this is a valid response with no text (e.g. silence)
                if let candidates = json["candidates"] as? [[String: Any]],
                   let content = candidates.first?["content"] as? [String: Any],
                   content["parts"] == nil {
                    log("[REST] No text in response — likely silence or unintelligible audio")
                    completion(.failure(NSError(domain: "XiaBB", code: -2, userInfo: [NSLocalizedDescriptionKey: "No speech detected"])))
                    return
                }
                let raw = String(data: data.prefix(500), encoding: .utf8) ?? "binary"
                log("[REST] Unexpected structure: \(raw)")
                completion(.failure(NSError(domain: "XiaBB", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unexpected response format"])))
                return
            }

            let cleaned = t2s(text.trimmingCharacters(in: .whitespacesAndNewlines)
                .replacingOccurrences(of: "\n", with: " "))
            log("[REST] ✅ Transcribed: \(cleaned.prefix(100))")
            completion(.success(cleaned))
        } catch {
            log("[REST] JSON parse error: \(error)")
            completion(.failure(error))
        }
    }.resume()
}

// MARK: - Gemini Live WebSocket

class LiveSession: NSObject, URLSessionWebSocketDelegate {
    private var webSocket: URLSessionWebSocketTask?
    private var urlSession: URLSession?
    private var liveText = ""
    private var onTextUpdate: ((String) -> Void)?
    private var isActive = false
    private var chunksSent = 0

    func start(onTextUpdate: @escaping (String) -> Void) {
        self.onTextUpdate = onTextUpdate
        self.liveText = ""
        self.isActive = true
        self.chunksSent = 0

        let urlStr = "\(WS_URL_BASE)?key=\(apiKey)"
        guard let url = URL(string: urlStr) else {
            log("[live] Invalid WebSocket URL")
            return
        }

        log("[live] Connecting to WebSocket...")
        let config = URLSessionConfiguration.default
        config.timeoutIntervalForRequest = 30
        urlSession = URLSession(configuration: config, delegate: self, delegateQueue: nil)
        webSocket = urlSession?.webSocketTask(with: url)
        webSocket?.resume()

        // Send setup message
        let setup: [String: Any] = [
            "setup": [
                "model": "models/\(MODEL_LIVE)",
                "generationConfig": ["responseModalities": ["AUDIO"]],
                "systemInstruction": ["parts": [["text": "Listen and acknowledge briefly."]]],
                "inputAudioTranscription": [:] as [String: Any],
            ]
        ]

        guard let setupData = try? JSONSerialization.data(withJSONObject: setup),
              let setupStr = String(data: setupData, encoding: .utf8) else {
            log("[live] Failed to serialize setup message")
            return
        }

        webSocket?.send(.string(setupStr)) { [weak self] error in
            if let error = error {
                log("[live] Setup send error: \(error)")
                return
            }
            log("[live] Setup message sent, waiting for response...")
            self?.receiveSetupResponse()
        }
    }

    // URLSessionWebSocketDelegate
    func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
        log("[live] WebSocket opened (protocol: \(`protocol` ?? "none"))")
    }

    func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
        log("[live] WebSocket closed (code: \(closeCode.rawValue))")
    }

    private func receiveSetupResponse() {
        webSocket?.receive { [weak self] result in
            switch result {
            case .success(let msg):
                switch msg {
                case .string(let text):
                    if text.contains("setupComplete") {
                        log("[live] ✅ Setup complete (string)")
                        self?.startReceiving()
                    } else {
                        log("[live] Unexpected setup response: \(text.prefix(300))")
                        self?.stop()
                    }
                case .data(let data):
                    // Log raw bytes to understand format
                    let hex = data.prefix(60).map { String(format: "%02x", $0) }.joined(separator: " ")
                    log("[live] Setup binary (\(data.count) bytes): \(hex)")
                    if let text = String(data: data, encoding: .utf8) {
                        log("[live] Setup as UTF-8: \(text.prefix(200))")
                        if text.contains("setupComplete") {
                            log("[live] ✅ Setup complete (from binary)")
                            self?.startReceiving()
                        } else {
                            // Maybe it's JSON with setup info, continue anyway
                            log("[live] ⚠️ No setupComplete in binary, starting anyway")
                            self?.startReceiving()
                        }
                    } else {
                        // Likely protobuf — start receiving anyway
                        log("[live] ⚠️ Non-UTF8 binary, assuming setup complete")
                        self?.startReceiving()
                    }
                @unknown default:
                    break
                }
            case .failure(let error):
                log("[live] Setup receive error: \(error)")
            }
        }
    }

    private var msgCount = 0

    private func startReceiving() {
        guard isActive else { return }
        webSocket?.receive { [weak self] result in
            guard let self = self, self.isActive else { return }
            switch result {
            case .success(let msg):
                self.msgCount += 1
                switch msg {
                case .string(let text):
                    self.handleMessage(text)
                case .data(let data):
                    // Try UTF-8 first
                    if let text = String(data: data, encoding: .utf8) {
                        self.handleMessage(text)
                    } else if self.msgCount <= 10 {
                        let hex = data.prefix(40).map { String(format: "%02x", $0) }.joined(separator: " ")
                        log("[live] Binary msg #\(self.msgCount): \(data.count) bytes — \(hex)...")
                    }
                @unknown default:
                    break
                }
                self.startReceiving() // Continue receiving
            case .failure(let error):
                if self.isActive {
                    log("[live] Receive error: \(error)")
                }
            }
        }
    }

    private func handleMessage(_ jsonStr: String) {
        // Log first few messages to debug
        if msgCount <= 5 {
            log("[live] Msg #\(msgCount): \(jsonStr.prefix(300))")
        }

        guard let data = jsonStr.data(using: .utf8),
              let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
            if msgCount <= 5 {
                log("[live] Failed to parse JSON for msg #\(msgCount)")
            }
            return
        }

        // Check for inputTranscription in serverContent
        if let sc = json["serverContent"] as? [String: Any] {
            if let it = sc["inputTranscription"] as? [String: Any],
               let chunk = it["text"] as? String, !chunk.isEmpty {
                liveText += chunk
                let display = t2s(liveText)
                log("[live] 📝 Chunk: \"\(chunk)\" → total: \"\(display.suffix(60))\"")
                DispatchQueue.main.async { [weak self] in
                    self?.onTextUpdate?(display)
                }
            }

            // Also check for modelTurn (the model's audio response) — we can ignore these
            if sc["modelTurn"] != nil {
                if msgCount <= 5 {
                    log("[live] Got modelTurn (ignored)")
                }
            }

            // Check turnComplete
            if let tc = sc["turnComplete"] as? Bool, tc {
                log("[live] Turn complete")
            }
        }
    }

    var currentText: String { return t2s(liveText) }

    func sendAudio(_ pcmData: Data) {
        guard isActive, let ws = webSocket else { return }
        let b64 = pcmData.base64EncodedString()
        let msg: [String: Any] = [
            "realtimeInput": [
                "mediaChunks": [[
                    "mimeType": "audio/pcm;rate=16000",
                    "data": b64
                ]]
            ]
        ]
        guard let data = try? JSONSerialization.data(withJSONObject: msg),
              let str = String(data: data, encoding: .utf8) else { return }
        ws.send(.string(str)) { [weak self] error in
            if let error = error {
                log("[live] Audio send error: \(error)")
            } else {
                self?.chunksSent += 1
            }
        }
    }

    func stop() {
        let sent = chunksSent
        isActive = false
        webSocket?.cancel(with: .goingAway, reason: nil)
        webSocket = nil
        urlSession?.invalidateAndCancel()
        urlSession = nil
        log("[live] Stopped — \(liveText.count) chars transcribed, \(sent) chunks sent")
    }
}

// MARK: - Onboarding Window (WKWebView-based)

func OB(_ key: String) -> String {
    // minimal shim for the menu item title used in XiaBBApp
    let zh: [String: String] = ["perm_menu": "设置向导..."]
    let en: [String: String] = ["perm_menu": "Setup Wizard..."]
    return (currentLang == "zh" ? zh[key] : en[key]) ?? key
}

// MARK: - Onboarding HTML

private let onboardingHTML = """
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>XiaBB Setup</title>
<style>
  * { box-sizing: border-box; margin: 0; padding: 0; }

  :root {
    --blue: #3b82f6;
    --blue-light: #eff6ff;
    --blue-dark: #2563eb;
    --green: #22c55e;
    --green-light: #f0fdf4;
    --red: #ef4444;
    --red-light: #fef2f2;
    --gray-50: #f9fafb;
    --gray-100: #f3f4f6;
    --gray-200: #e5e7eb;
    --gray-400: #9ca3af;
    --gray-500: #6b7280;
    --gray-700: #374151;
    --gray-900: #111827;
  }

  body {
    font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif;
    background: #ffffff;
    color: var(--gray-900);
    height: 550px;
    overflow: hidden;
    -webkit-font-smoothing: antialiased;
    user-select: none;
    -webkit-user-select: none;
  }

  .step {
    display: none;
    position: absolute;
    inset: 0;
    flex-direction: column;
    opacity: 0;
    transition: opacity 0.25s ease;
  }
  .step.active { display: flex; opacity: 1; }
  .step.fade-in { opacity: 1; }

  .step-bar {
    display: flex; align-items: center; justify-content: center;
    gap: 8px; padding: 18px 0 0; min-height: 48px;
  }
  .step-dot {
    width: 8px; height: 8px; border-radius: 50%;
    background: var(--gray-200); transition: all 0.3s ease;
  }
  .step-dot.done   { background: var(--blue); }
  .step-dot.active { background: var(--blue); width: 20px; border-radius: 4px; }

  .step-content {
    flex: 1; overflow-y: auto; padding: 0 40px 16px;
    display: flex; flex-direction: column;
  }
  .step-nav {
    display: flex; align-items: center; justify-content: space-between;
    padding: 12px 32px 20px; border-top: 1px solid var(--gray-100);
    background: #fff; min-height: 64px;
  }

  h1 { font-size: 26px; font-weight: 700; line-height: 1.2; }
  h2 { font-size: 20px; font-weight: 600; line-height: 1.3; margin-bottom: 8px; }
  p  { font-size: 14px; color: var(--gray-500); line-height: 1.6; }
  .hint { font-size: 12px; color: var(--gray-400); margin-top: 6px; }

  .btn {
    display: inline-flex; align-items: center; justify-content: center; gap: 6px;
    padding: 9px 20px; border-radius: 10px; font-size: 14px; font-weight: 500;
    border: none; cursor: pointer; transition: all 0.15s ease; outline: none;
    -webkit-app-region: no-drag;
  }
  .btn-primary { background: var(--blue); color: #fff; box-shadow: 0 1px 3px rgba(59,130,246,0.3); }
  .btn-primary:hover { background: var(--blue-dark); }
  .btn-primary:active { transform: scale(0.97); }
  .btn-primary:disabled { background: var(--gray-200); color: var(--gray-400); box-shadow: none; cursor: not-allowed; }
  .btn-secondary { background: var(--gray-100); color: var(--gray-700); border: 1px solid var(--gray-200); }
  .btn-secondary:hover { background: var(--gray-200); }
  .btn-ghost { background: transparent; color: var(--gray-500); font-size: 13px; padding: 6px 12px; }
  .btn-ghost:hover { color: var(--gray-700); }
  .btn-large { padding: 13px 36px; font-size: 16px; font-weight: 600; border-radius: 12px; min-width: 200px; }

  .card {
    background: var(--gray-50); border: 1px solid var(--gray-200);
    border-radius: 12px; padding: 16px 20px; margin: 8px 0;
  }
  .card-row { display: flex; align-items: center; gap: 14px; }
  .card-icon { font-size: 22px; flex-shrink: 0; }
  .card-body { flex: 1; }
  .card-title { font-size: 14px; font-weight: 500; margin-bottom: 2px; }
  .card-desc  { font-size: 12px; color: var(--gray-500); }
  .card-action { flex-shrink: 0; }

  .badge {
    display: inline-flex; align-items: center; gap: 5px;
    padding: 4px 10px; border-radius: 20px; font-size: 12px; font-weight: 500;
  }
  .badge-success { background: var(--green-light); color: #15803d; }
  .badge-pending { background: var(--gray-100);    color: var(--gray-500); }
  .badge-error   { background: var(--red-light);   color: #b91c1c; }
  .badge-loading { background: var(--blue-light);  color: var(--blue-dark); }

  .input-row { display: flex; gap: 8px; margin: 12px 0; }
  .input-field {
    flex: 1; padding: 9px 14px; border: 1.5px solid var(--gray-200);
    border-radius: 10px; font-size: 13px; font-family: "SF Mono", monospace;
    color: var(--gray-900); background: #fff; outline: none;
    transition: border-color 0.15s; -webkit-app-region: no-drag;
  }
  .input-field:focus { border-color: var(--blue); }
  .input-field.error   { border-color: var(--red); }
  .input-field.success { border-color: var(--green); }

  .status-row { min-height: 24px; margin-top: 6px; display: flex; align-items: center; gap: 8px; }

  .mic-viz {
    display: flex; align-items: flex-end; justify-content: center;
    gap: 3px; height: 56px; margin: 16px 0;
  }
  .mic-bar {
    width: 10px; background: var(--gray-200); border-radius: 3px;
    min-height: 4px; transition: height 0.06s ease-out, background 0.06s ease-out;
  }
  .mic-bar.active { background: var(--blue); }

  .app-icon {
    display: inline-flex; align-items: center; justify-content: center;
    background: #fff; border-radius: 22%;
    box-shadow: 0 2px 8px rgba(0,0,0,0.12), 0 1px 3px rgba(0,0,0,0.08);
    line-height: 1; flex-shrink: 0;
  }
  .app-icon-lg { width: 80px; height: 80px; font-size: 48px; border-radius: 18px; margin-bottom: 16px; box-shadow: 0 4px 16px rgba(0,0,0,0.14), 0 1px 4px rgba(0,0,0,0.10); }
  .app-icon-sm { width: 32px; height: 32px; font-size: 20px; border-radius: 7px; }
  .app-icon svg { width: 65%; height: 65%; display: block; }

  .step-header {
    display: flex; align-items: center; gap: 10px;
    padding: 14px 20px 0;
  }

  .globe-key {
    width: 72px; height: 72px; border-radius: 16px;
    background: var(--gray-100); border: 2px solid var(--gray-200);
    display: flex; align-items: center; justify-content: center;
    font-size: 36px; margin: 20px auto 16px; transition: all 0.15s ease;
  }
  .globe-key.pressed { background: #eff6ff; border-color: var(--blue); transform: scale(1.10); box-shadow: 0 0 0 4px rgba(59,130,246,0.2); }
  .globe-key.detected { background: #eff6ff; border-color: var(--blue); transform: scale(1.05); }

  /* Step 5 mock window */
  .mock-window {
    background: #fff;
    border-radius: 12px;
    border: 1px solid #e5e7eb;
    box-shadow: 0 4px 16px rgba(0,0,0,0.08);
    overflow: hidden;
  }
  .mock-titlebar {
    background: #f9fafb;
    border-bottom: 1px solid #e5e7eb;
    padding: 8px 12px;
    display: flex;
    align-items: center;
    gap: 6px;
  }
  .mock-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
  .mock-body {
    padding: 16px;
    min-height: 150px;
    font-size: 15px;
    line-height: 1.6;
    color: #1f2937;
  }

  @keyframes globePulse {
    0%   { box-shadow: 0 0 0 0 rgba(34,197,94,0.4); }
    70%  { box-shadow: 0 0 0 10px rgba(34,197,94,0); }
    100% { box-shadow: 0 0 0 0 rgba(34,197,94,0); }
  }
  /* pulse removed — just two states: default (gray) and pressed (blue) */

  .checklist { list-style: none; margin: 16px 0; }
  .checklist li {
    display: flex; align-items: center; gap: 12px;
    padding: 10px 0; border-bottom: 1px solid var(--gray-100); font-size: 14px;
  }
  .checklist li:last-child { border-bottom: none; }
  .check-icon { font-size: 18px; }
  .check-label { flex: 1; }
  .check-status { font-size: 12px; color: var(--gray-400); }

  .divider { border: none; border-top: 1px solid var(--gray-100); margin: 12px 0; }

  .link-btn {
    color: var(--blue); font-size: 13px; cursor: pointer;
    text-decoration: none; display: inline-flex; align-items: center; gap: 4px;
    background: none; border: none; padding: 0; font-family: inherit;
    -webkit-app-region: no-drag;
  }
  .link-btn:hover { text-decoration: underline; }

  #step-0 { justify-content: center; text-align: center; }
  .welcome-content {
    padding: 24px 40px; display: flex; flex-direction: column; align-items: center;
  }
  .app-name { font-size: 28px; font-weight: 700; margin-bottom: 10px; }
  .app-desc { font-size: 15px; color: var(--gray-500); line-height: 1.7; max-width: 380px; }

  @keyframes spin { to { transform: rotate(360deg); } }
  .spinner {
    width: 14px; height: 14px; border: 2px solid var(--gray-200);
    border-top-color: var(--blue); border-radius: 50%;
    animation: spin 0.7s linear infinite; flex-shrink: 0; display: inline-block;
  }

  @keyframes bounceIn {
    0%   { transform: scale(0.85); opacity: 0; }
    60%  { transform: scale(1.05); opacity: 1; }
    100% { transform: scale(1); }
  }
  .bounce-in { animation: bounceIn 0.4s ease; }

  ::-webkit-scrollbar { width: 5px; }
  ::-webkit-scrollbar-thumb { background: var(--gray-200); border-radius: 10px; }
</style>
</head>
<body>

<!-- STEP 0: WELCOME -->
<div class="step active" id="step-0">
  <div class="welcome-content" style="flex:1; justify-content:center;">
    <div id="logoArea" class="app-icon app-icon-lg"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="#000000"><g transform="translate(0,512) scale(0.1,-0.1)" stroke="none"><path d="M1966 4593 c-52 -19 -61 -29 -47 -51 6 -11 16 -11 47 -3 179 50 341 -76 368 -284 l6 -50 -53 -29 c-123 -70 -241 -239 -254 -369 l-6 -57 150 0 c133 0 152 -2 166 -18 21 -23 22 -46 2 -66 -12 -12 -43 -15 -153 -15 -75 1 -143 0 -149 0 -8 -1 -13 -17 -13 -46 l0 -44 156 -3 c134 -3 158 -5 167 -20 8 -12 8 -24 -1 -42 l-12 -26 -155 0 -155 0 0 -45 0 -45 153 0 c161 0 177 -4 177 -45 0 -41 -16 -45 -177 -45 l-153 0 0 -45 0 -45 153 0 c161 0 177 -4 177 -45 0 -41 -16 -45 -177 -45 l-153 0 0 -40 0 -40 83 -1 c268 -4 973 4 980 11 5 4 7 20 5 36 l-3 29 -158 5 c-125 4 -160 8 -167 20 -13 20 -12 33 3 53 10 14 36 17 167 19 l155 3 0 40 0 40 -155 3 c-131 2 -157 5 -167 19 -17 22 -16 38 3 57 13 13 42 16 165 16 l149 0 6 24 c3 14 3 34 -1 45 -6 20 -13 21 -155 21 -147 0 -150 0 -165 24 -13 19 -14 29 -5 45 10 20 19 21 165 21 l155 0 0 45 0 45 -145 0 c-132 0 -147 2 -165 20 -11 11 -20 25 -20 30 0 6 9 19 20 30 18 18 33 20 170 20 171 0 161 -7 135 95 -34 128 -117 243 -232 317 l-65 43 5 50 c13 122 81 226 180 274 42 20 58 23 120 18 40 -3 81 -7 92 -9 14 -2 21 3 23 19 3 19 -5 24 -49 37 -66 20 -102 20 -165 1 -68 -20 -140 -69 -180 -124 -39 -54 -74 -149 -74 -204 l0 -39 -52 9 c-67 10 -201 10 -253 0 l-41 -8 -11 63 c-15 88 -49 154 -110 216 -88 89 -205 121 -307 85z"/><path d="M1787 2982 c-13 -14 -17 -39 -17 -110 0 -87 1 -92 25 -108 l25 -16 0 -347 c0 -365 5 -410 50 -515 58 -137 181 -267 319 -340 72 -38 197 -76 248 -76 l33 0 0 -273 0 -274 -132 -7 c-198 -9 -394 -35 -468 -59 -56 -19 -65 -25 -65 -46 0 -30 41 -45 170 -66 251 -40 825 -45 1095 -11 142 18 260 50 260 70 0 35 -79 66 -220 85 -99 14 -317 31 -396 31 l-64 0 0 274 0 273 55 7 c290 34 541 271 594 560 7 35 11 196 11 390 0 320 1 332 20 344 18 11 20 23 20 112 0 114 -4 120 -85 120 -81 0 -85 -6 -85 -120 0 -89 2 -101 20 -112 19 -12 20 -23 20 -333 -1 -358 -9 -433 -58 -536 -67 -140 -194 -253 -342 -305 -100 -34 -323 -45 -440 -20 -221 46 -400 216 -455 431 -12 47 -15 128 -15 407 0 320 1 348 18 357 14 8 17 24 17 104 0 117 -6 127 -85 127 -42 0 -61 -5 -73 -18z"/><path d="M2030 2600 c0 -318 1 -345 21 -403 40 -121 116 -218 223 -286 166 -104 344 -115 523 -31 81 37 190 140 231 218 59 112 62 133 62 504 l0 338 -530 0 -530 0 0 -340z"/><path d="M1802 712 c3 -36 5 -38 70 -59 132 -44 282 -57 673 -58 362 0 492 8 655 41 108 23 130 36 130 80 0 31 -3 35 -17 29 -142 -59 -595 -94 -958 -75 -240 12 -409 32 -496 60 l-60 18 3 -36z"/></g></svg></div>
    <div class="app-name" id="s0-title">欢迎使用虾BB</div>
    <div class="app-desc" id="s0-desc">专为 Vibe Coder 打造的语音输入。中英混说，Claude、OpenClaw、GitHub 精准识别。免费，开源，280KB。</div>
    <div style="height:28px"></div>
    <button class="btn btn-primary btn-large bounce-in" onclick="startSetup()" id="s0-btn">开始设置</button>
    <div style="height:16px"></div>
    <div class="hint" id="s0-hint">设置只需 2 分钟</div>
  </div>
</div>

<!-- STEP 1: API KEY -->
<div class="step" id="step-1">
  <div class="step-header">
    <div class="app-icon app-icon-sm"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="#000000"><g transform="translate(0,512) scale(0.1,-0.1)" stroke="none"><path d="M1966 4593 c-52 -19 -61 -29 -47 -51 6 -11 16 -11 47 -3 179 50 341 -76 368 -284 l6 -50 -53 -29 c-123 -70 -241 -239 -254 -369 l-6 -57 150 0 c133 0 152 -2 166 -18 21 -23 22 -46 2 -66 -12 -12 -43 -15 -153 -15 -75 1 -143 0 -149 0 -8 -1 -13 -17 -13 -46 l0 -44 156 -3 c134 -3 158 -5 167 -20 8 -12 8 -24 -1 -42 l-12 -26 -155 0 -155 0 0 -45 0 -45 153 0 c161 0 177 -4 177 -45 0 -41 -16 -45 -177 -45 l-153 0 0 -45 0 -45 153 0 c161 0 177 -4 177 -45 0 -41 -16 -45 -177 -45 l-153 0 0 -40 0 -40 83 -1 c268 -4 973 4 980 11 5 4 7 20 5 36 l-3 29 -158 5 c-125 4 -160 8 -167 20 -13 20 -12 33 3 53 10 14 36 17 167 19 l155 3 0 40 0 40 -155 3 c-131 2 -157 5 -167 19 -17 22 -16 38 3 57 13 13 42 16 165 16 l149 0 6 24 c3 14 3 34 -1 45 -6 20 -13 21 -155 21 -147 0 -150 0 -165 24 -13 19 -14 29 -5 45 10 20 19 21 165 21 l155 0 0 45 0 45 -145 0 c-132 0 -147 2 -165 20 -11 11 -20 25 -20 30 0 6 9 19 20 30 18 18 33 20 170 20 171 0 161 -7 135 95 -34 128 -117 243 -232 317 l-65 43 5 50 c13 122 81 226 180 274 42 20 58 23 120 18 40 -3 81 -7 92 -9 14 -2 21 3 23 19 3 19 -5 24 -49 37 -66 20 -102 20 -165 1 -68 -20 -140 -69 -180 -124 -39 -54 -74 -149 -74 -204 l0 -39 -52 9 c-67 10 -201 10 -253 0 l-41 -8 -11 63 c-15 88 -49 154 -110 216 -88 89 -205 121 -307 85z"/><path d="M1787 2982 c-13 -14 -17 -39 -17 -110 0 -87 1 -92 25 -108 l25 -16 0 -347 c0 -365 5 -410 50 -515 58 -137 181 -267 319 -340 72 -38 197 -76 248 -76 l33 0 0 -273 0 -274 -132 -7 c-198 -9 -394 -35 -468 -59 -56 -19 -65 -25 -65 -46 0 -30 41 -45 170 -66 251 -40 825 -45 1095 -11 142 18 260 50 260 70 0 35 -79 66 -220 85 -99 14 -317 31 -396 31 l-64 0 0 274 0 273 55 7 c290 34 541 271 594 560 7 35 11 196 11 390 0 320 1 332 20 344 18 11 20 23 20 112 0 114 -4 120 -85 120 -81 0 -85 -6 -85 -120 0 -89 2 -101 20 -112 19 -12 20 -23 20 -333 -1 -358 -9 -433 -58 -536 -67 -140 -194 -253 -342 -305 -100 -34 -323 -45 -440 -20 -221 46 -400 216 -455 431 -12 47 -15 128 -15 407 0 320 1 348 18 357 14 8 17 24 17 104 0 117 -6 127 -85 127 -42 0 -61 -5 -73 -18z"/><path d="M2030 2600 c0 -318 1 -345 21 -403 40 -121 116 -218 223 -286 166 -104 344 -115 523 -31 81 37 190 140 231 218 59 112 62 133 62 504 l0 338 -530 0 -530 0 0 -340z"/><path d="M1802 712 c3 -36 5 -38 70 -59 132 -44 282 -57 673 -58 362 0 492 8 655 41 108 23 130 36 130 80 0 31 -3 35 -17 29 -142 -59 -595 -94 -958 -75 -240 12 -409 32 -496 60 l-60 18 3 -36z"/></g></svg></div>
    <div class="step-bar" id="dots-1" style="flex:1; padding:0; min-height:unset;"></div>
  </div>
  <div class="step-content" style="padding-top:10px;">
    <h2 id="s1-title">配置 API Key</h2>
    <p id="s1-desc">虾BB 使用 Google Gemini 进行语音识别。免费额度：每天 250 次，无需信用卡。</p>
    <div style="height:12px"></div>

    <div class="card" id="s1-existing" style="display:none">
      <div class="card-row">
        <div class="card-icon">🔑</div>
        <div class="card-body">
          <div class="card-title" id="s1-existing-label">已配置 Key</div>
          <div class="card-desc" id="s1-existing-masked"></div>
        </div>
        <div class="card-action">
          <button class="btn btn-secondary" style="font-size:12px;padding:6px 12px" onclick="changeKey()" id="s1-change-btn">更换</button>
        </div>
      </div>
    </div>

    <div id="s1-input-area">
      <div class="input-row">
        <input type="text" class="input-field" id="apiKeyInput" spellcheck="false"
               placeholder="粘贴 API Key..." oninput="onKeyInput()" onkeydown="onKeyDown(event)">
        <button class="btn btn-primary" id="validateBtn" onclick="doValidate()">验证</button>
      </div>
      <div class="status-row" id="keyStatusRow">
        <span id="keyStatusIcon"></span>
        <span id="keyStatusText"></span>
        <div id="keyStatusSpinner" class="spinner" style="display:none"></div>
      </div>
    </div>

    <div style="height:8px"></div>
    <button class="link-btn" onclick="openAIStudio()">前往 Google AI Studio 获取免费 Key →</button>
    <div class="hint" id="s1-hint">免费版每天 250 次请求</div>
  </div>
  <div class="step-nav">
    <button class="btn btn-ghost" onclick="goBack()" id="nav-back-1">返回</button>
    <button class="btn btn-primary" onclick="goNext()" id="nav-next-1" disabled>下一步</button>
  </div>
</div>

<!-- STEP 2: MICROPHONE -->
<div class="step" id="step-2">
  <div class="step-header">
    <div class="app-icon app-icon-sm"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="#000000"><g transform="translate(0,512) scale(0.1,-0.1)" stroke="none"><path d="M1966 4593 c-52 -19 -61 -29 -47 -51 6 -11 16 -11 47 -3 179 50 341 -76 368 -284 l6 -50 -53 -29 c-123 -70 -241 -239 -254 -369 l-6 -57 150 0 c133 0 152 -2 166 -18 21 -23 22 -46 2 -66 -12 -12 -43 -15 -153 -15 -75 1 -143 0 -149 0 -8 -1 -13 -17 -13 -46 l0 -44 156 -3 c134 -3 158 -5 167 -20 8 -12 8 -24 -1 -42 l-12 -26 -155 0 -155 0 0 -45 0 -45 153 0 c161 0 177 -4 177 -45 0 -41 -16 -45 -177 -45 l-153 0 0 -45 0 -45 153 0 c161 0 177 -4 177 -45 0 -41 -16 -45 -177 -45 l-153 0 0 -40 0 -40 83 -1 c268 -4 973 4 980 11 5 4 7 20 5 36 l-3 29 -158 5 c-125 4 -160 8 -167 20 -13 20 -12 33 3 53 10 14 36 17 167 19 l155 3 0 40 0 40 -155 3 c-131 2 -157 5 -167 19 -17 22 -16 38 3 57 13 13 42 16 165 16 l149 0 6 24 c3 14 3 34 -1 45 -6 20 -13 21 -155 21 -147 0 -150 0 -165 24 -13 19 -14 29 -5 45 10 20 19 21 165 21 l155 0 0 45 0 45 -145 0 c-132 0 -147 2 -165 20 -11 11 -20 25 -20 30 0 6 9 19 20 30 18 18 33 20 170 20 171 0 161 -7 135 95 -34 128 -117 243 -232 317 l-65 43 5 50 c13 122 81 226 180 274 42 20 58 23 120 18 40 -3 81 -7 92 -9 14 -2 21 3 23 19 3 19 -5 24 -49 37 -66 20 -102 20 -165 1 -68 -20 -140 -69 -180 -124 -39 -54 -74 -149 -74 -204 l0 -39 -52 9 c-67 10 -201 10 -253 0 l-41 -8 -11 63 c-15 88 -49 154 -110 216 -88 89 -205 121 -307 85z"/><path d="M1787 2982 c-13 -14 -17 -39 -17 -110 0 -87 1 -92 25 -108 l25 -16 0 -347 c0 -365 5 -410 50 -515 58 -137 181 -267 319 -340 72 -38 197 -76 248 -76 l33 0 0 -273 0 -274 -132 -7 c-198 -9 -394 -35 -468 -59 -56 -19 -65 -25 -65 -46 0 -30 41 -45 170 -66 251 -40 825 -45 1095 -11 142 18 260 50 260 70 0 35 -79 66 -220 85 -99 14 -317 31 -396 31 l-64 0 0 274 0 273 55 7 c290 34 541 271 594 560 7 35 11 196 11 390 0 320 1 332 20 344 18 11 20 23 20 112 0 114 -4 120 -85 120 -81 0 -85 -6 -85 -120 0 -89 2 -101 20 -112 19 -12 20 -23 20 -333 -1 -358 -9 -433 -58 -536 -67 -140 -194 -253 -342 -305 -100 -34 -323 -45 -440 -20 -221 46 -400 216 -455 431 -12 47 -15 128 -15 407 0 320 1 348 18 357 14 8 17 24 17 104 0 117 -6 127 -85 127 -42 0 -61 -5 -73 -18z"/><path d="M2030 2600 c0 -318 1 -345 21 -403 40 -121 116 -218 223 -286 166 -104 344 -115 523 -31 81 37 190 140 231 218 59 112 62 133 62 504 l0 338 -530 0 -530 0 0 -340z"/><path d="M1802 712 c3 -36 5 -38 70 -59 132 -44 282 -57 673 -58 362 0 492 8 655 41 108 23 130 36 130 80 0 31 -3 35 -17 29 -142 -59 -595 -94 -958 -75 -240 12 -409 32 -496 60 l-60 18 3 -36z"/></g></svg></div>
    <div class="step-bar" id="dots-2" style="flex:1; padding:0; min-height:unset;"></div>
  </div>
  <div class="step-content" style="padding-top:10px;">
    <h2 id="s2-title">麦克风权限</h2>
    <p id="s2-desc">虾BB 需要麦克风权限来录制你的声音。</p>
    <div style="height:14px"></div>

    <div class="card">
      <div class="card-row">
        <div class="card-icon">🎙</div>
        <div class="card-body">
          <div class="card-title" id="s2-card-title">麦克风</div>
          <div class="card-desc" id="s2-status-desc">等待授权...</div>
        </div>
        <div class="card-action" id="s2-grant-area">
          <button class="btn btn-primary" style="font-size:13px" onclick="doRequestMic()" id="s2-grant-btn">允许</button>
        </div>
      </div>
    </div>

    <div class="mic-viz" id="micViz">
      <div class="mic-bar" id="bar0"  style="height:4px"></div>
      <div class="mic-bar" id="bar1"  style="height:4px"></div>
      <div class="mic-bar" id="bar2"  style="height:4px"></div>
      <div class="mic-bar" id="bar3"  style="height:4px"></div>
      <div class="mic-bar" id="bar4"  style="height:4px"></div>
      <div class="mic-bar" id="bar5"  style="height:4px"></div>
      <div class="mic-bar" id="bar6"  style="height:4px"></div>
      <div class="mic-bar" id="bar7"  style="height:4px"></div>
      <div class="mic-bar" id="bar8"  style="height:4px"></div>
      <div class="mic-bar" id="bar9"  style="height:4px"></div>
      <div class="mic-bar" id="bar10" style="height:4px"></div>
      <div class="mic-bar" id="bar11" style="height:4px"></div>
    </div>

    <p style="text-align:center; font-size:13px; color:var(--gray-500)" id="s2-q">说话时你能看到音量条在动吗？</p>
    <div style="display:flex; gap:10px; justify-content:center; margin-top:12px" id="s2-confirm-row">
      <button class="btn btn-primary" onclick="micYes()" id="s2-yes-btn">可以，继续</button>
      <button class="btn btn-secondary" onclick="micNo()" id="s2-no-btn">不行，换麦克风</button>
    </div>
  </div>
  <div class="step-nav">
    <button class="btn btn-ghost" onclick="goBack()" id="nav-back-2">返回</button>
    <button class="btn btn-primary" onclick="goNext()" id="nav-next-2" disabled>下一步</button>
  </div>
</div>

<!-- STEP 3: ACCESSIBILITY -->
<div class="step" id="step-3">
  <div class="step-header">
    <div class="app-icon app-icon-sm"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="#000000"><g transform="translate(0,512) scale(0.1,-0.1)" stroke="none"><path d="M1966 4593 c-52 -19 -61 -29 -47 -51 6 -11 16 -11 47 -3 179 50 341 -76 368 -284 l6 -50 -53 -29 c-123 -70 -241 -239 -254 -369 l-6 -57 150 0 c133 0 152 -2 166 -18 21 -23 22 -46 2 -66 -12 -12 -43 -15 -153 -15 -75 1 -143 0 -149 0 -8 -1 -13 -17 -13 -46 l0 -44 156 -3 c134 -3 158 -5 167 -20 8 -12 8 -24 -1 -42 l-12 -26 -155 0 -155 0 0 -45 0 -45 153 0 c161 0 177 -4 177 -45 0 -41 -16 -45 -177 -45 l-153 0 0 -45 0 -45 153 0 c161 0 177 -4 177 -45 0 -41 -16 -45 -177 -45 l-153 0 0 -40 0 -40 83 -1 c268 -4 973 4 980 11 5 4 7 20 5 36 l-3 29 -158 5 c-125 4 -160 8 -167 20 -13 20 -12 33 3 53 10 14 36 17 167 19 l155 3 0 40 0 40 -155 3 c-131 2 -157 5 -167 19 -17 22 -16 38 3 57 13 13 42 16 165 16 l149 0 6 24 c3 14 3 34 -1 45 -6 20 -13 21 -155 21 -147 0 -150 0 -165 24 -13 19 -14 29 -5 45 10 20 19 21 165 21 l155 0 0 45 0 45 -145 0 c-132 0 -147 2 -165 20 -11 11 -20 25 -20 30 0 6 9 19 20 30 18 18 33 20 170 20 171 0 161 -7 135 95 -34 128 -117 243 -232 317 l-65 43 5 50 c13 122 81 226 180 274 42 20 58 23 120 18 40 -3 81 -7 92 -9 14 -2 21 3 23 19 3 19 -5 24 -49 37 -66 20 -102 20 -165 1 -68 -20 -140 -69 -180 -124 -39 -54 -74 -149 -74 -204 l0 -39 -52 9 c-67 10 -201 10 -253 0 l-41 -8 -11 63 c-15 88 -49 154 -110 216 -88 89 -205 121 -307 85z"/><path d="M1787 2982 c-13 -14 -17 -39 -17 -110 0 -87 1 -92 25 -108 l25 -16 0 -347 c0 -365 5 -410 50 -515 58 -137 181 -267 319 -340 72 -38 197 -76 248 -76 l33 0 0 -273 0 -274 -132 -7 c-198 -9 -394 -35 -468 -59 -56 -19 -65 -25 -65 -46 0 -30 41 -45 170 -66 251 -40 825 -45 1095 -11 142 18 260 50 260 70 0 35 -79 66 -220 85 -99 14 -317 31 -396 31 l-64 0 0 274 0 273 55 7 c290 34 541 271 594 560 7 35 11 196 11 390 0 320 1 332 20 344 18 11 20 23 20 112 0 114 -4 120 -85 120 -81 0 -85 -6 -85 -120 0 -89 2 -101 20 -112 19 -12 20 -23 20 -333 -1 -358 -9 -433 -58 -536 -67 -140 -194 -253 -342 -305 -100 -34 -323 -45 -440 -20 -221 46 -400 216 -455 431 -12 47 -15 128 -15 407 0 320 1 348 18 357 14 8 17 24 17 104 0 117 -6 127 -85 127 -42 0 -61 -5 -73 -18z"/><path d="M2030 2600 c0 -318 1 -345 21 -403 40 -121 116 -218 223 -286 166 -104 344 -115 523 -31 81 37 190 140 231 218 59 112 62 133 62 504 l0 338 -530 0 -530 0 0 -340z"/><path d="M1802 712 c3 -36 5 -38 70 -59 132 -44 282 -57 673 -58 362 0 492 8 655 41 108 23 130 36 130 80 0 31 -3 35 -17 29 -142 -59 -595 -94 -958 -75 -240 12 -409 32 -496 60 l-60 18 3 -36z"/></g></svg></div>
    <div class="step-bar" id="dots-3" style="flex:1; padding:0; min-height:unset;"></div>
  </div>
  <div class="step-content" style="padding-top:10px;">
    <h2 id="s3-title">辅助功能权限</h2>
    <p id="s3-desc">虾BB 需要辅助功能权限来检测 Globe 键（地球键）。授权后可能需要重启。</p>
    <div style="height:14px"></div>

    <div class="card">
      <div class="card-row">
        <div class="card-icon">⌨️</div>
        <div class="card-body">
          <div class="card-title" id="s3-card-title">辅助功能</div>
          <div class="card-desc" id="s3-status-desc">等待授权...</div>
        </div>
        <div class="card-action" id="s3-grant-area">
          <button class="btn btn-primary" style="font-size:13px" onclick="doRequestAcc()" id="s3-grant-btn">允许</button>
        </div>
      </div>
    </div>

    <div style="height:14px"></div>
    <p style="font-size:13px; color:var(--gray-500); text-align:center" id="s3-test-prompt">按下 Globe 键（地球键）测试</p>
    <div class="globe-key" id="globeKey">🌐</div>
    <p style="text-align:center; font-size:13px;" id="s3-detected-label"></p>

    <hr class="divider">
    <div style="display:flex; align-items:center; justify-content:space-between; margin-top:4px">
      <span class="hint" id="s3-restart-note">授权后若检测不到按键，可能需要重启虾BB。</span>
      <button class="btn btn-secondary" style="font-size:12px;padding:6px 14px" onclick="doRestart()" id="s3-restart-btn">重启</button>
    </div>
  </div>
  <div class="step-nav">
    <button class="btn btn-ghost" onclick="goBack()" id="nav-back-3">返回</button>
    <button class="btn btn-primary" onclick="goNext()" id="nav-next-3">下一步</button>
  </div>
</div>

<!-- STEP 4: TRY IT -->
<div class="step" id="step-5">
  <div class="step-header">
    <div class="app-icon app-icon-sm"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="#000000"><g transform="translate(0,512) scale(0.1,-0.1)" stroke="none"><path d="M1966 4593 c-52 -19 -61 -29 -47 -51 6 -11 16 -11 47 -3 179 50 341 -76 368 -284 l6 -50 -53 -29 c-123 -70 -241 -239 -254 -369 l-6 -57 150 0 c133 0 152 -2 166 -18 21 -23 22 -46 2 -66 -12 -12 -43 -15 -153 -15 -75 1 -143 0 -149 0 -8 -1 -13 -17 -13 -46 l0 -44 156 -3 c134 -3 158 -5 167 -20 8 -12 8 -24 -1 -42 l-12 -26 -155 0 -155 0 0 -45 0 -45 153 0 c161 0 177 -4 177 -45 0 -41 -16 -45 -177 -45 l-153 0 0 -45 0 -45 153 0 c161 0 177 -4 177 -45 0 -41 -16 -45 -177 -45 l-153 0 0 -40 0 -40 83 -1 c268 -4 973 4 980 11 5 4 7 20 5 36 l-3 29 -158 5 c-125 4 -160 8 -167 20 -13 20 -12 33 3 53 10 14 36 17 167 19 l155 3 0 40 0 40 -155 3 c-131 2 -157 5 -167 19 -17 22 -16 38 3 57 13 13 42 16 165 16 l149 0 6 24 c3 14 3 34 -1 45 -6 20 -13 21 -155 21 -147 0 -150 0 -165 24 -13 19 -14 29 -5 45 10 20 19 21 165 21 l155 0 0 45 0 45 -145 0 c-132 0 -147 2 -165 20 -11 11 -20 25 -20 30 0 6 9 19 20 30 18 18 33 20 170 20 171 0 161 -7 135 95 -34 128 -117 243 -232 317 l-65 43 5 50 c13 122 81 226 180 274 42 20 58 23 120 18 40 -3 81 -7 92 -9 14 -2 21 3 23 19 3 19 -5 24 -49 37 -66 20 -102 20 -165 1 -68 -20 -140 -69 -180 -124 -39 -54 -74 -149 -74 -204 l0 -39 -52 9 c-67 10 -201 10 -253 0 l-41 -8 -11 63 c-15 88 -49 154 -110 216 -88 89 -205 121 -307 85z"/><path d="M1787 2982 c-13 -14 -17 -39 -17 -110 0 -87 1 -92 25 -108 l25 -16 0 -347 c0 -365 5 -410 50 -515 58 -137 181 -267 319 -340 72 -38 197 -76 248 -76 l33 0 0 -273 0 -274 -132 -7 c-198 -9 -394 -35 -468 -59 -56 -19 -65 -25 -65 -46 0 -30 41 -45 170 -66 251 -40 825 -45 1095 -11 142 18 260 50 260 70 0 35 -79 66 -220 85 -99 14 -317 31 -396 31 l-64 0 0 274 0 273 55 7 c290 34 541 271 594 560 7 35 11 196 11 390 0 320 1 332 20 344 18 11 20 23 20 112 0 114 -4 120 -85 120 -81 0 -85 -6 -85 -120 0 -89 2 -101 20 -112 19 -12 20 -23 20 -333 -1 -358 -9 -433 -58 -536 -67 -140 -194 -253 -342 -305 -100 -34 -323 -45 -440 -20 -221 46 -400 216 -455 431 -12 47 -15 128 -15 407 0 320 1 348 18 357 14 8 17 24 17 104 0 117 -6 127 -85 127 -42 0 -61 -5 -73 -18z"/><path d="M2030 2600 c0 -318 1 -345 21 -403 40 -121 116 -218 223 -286 166 -104 344 -115 523 -31 81 37 190 140 231 218 59 112 62 133 62 504 l0 338 -530 0 -530 0 0 -340z"/><path d="M1802 712 c3 -36 5 -38 70 -59 132 -44 282 -57 673 -58 362 0 492 8 655 41 108 23 130 36 130 80 0 31 -3 35 -17 29 -142 -59 -595 -94 -958 -75 -240 12 -409 32 -496 60 l-60 18 3 -36z"/></g></svg></div>
    <div class="step-bar" id="dots-5" style="flex:1; padding:0; min-height:unset;"></div>
  </div>
  <div class="step-content" style="padding-top:10px;">
    <h2 id="s4-title">📖 智能词汇表</h2>
    <p id="s4-desc" style="margin-bottom:20px;">其他语音工具会把 "Claude" 识别成 "cloud"，把 "OpenClaw" 识别成 "open cloud"。虾BB 不会。</p>
    <!-- Feature cards instead of raw word list -->
    <div style="display:flex; flex-direction:column; gap:14px;">
      <div style="background:#f9fafb; border-radius:12px; padding:16px 18px; border:1px solid #f3f4f6;">
        <div style="font-size:15px; font-weight:600; color:#1f2937; margin-bottom:4px;">🤖 40+ AI 工具词汇预置</div>
        <div style="font-size:13px; color:#6b7280; line-height:1.6;">Claude、OpenClaw、Cursor、Gemini、Kimi、DeepSeek、ChatGPT 等 AI 工具名称都能被精确识别，不再张冠李戴。</div>
      </div>
      <div style="background:#f9fafb; border-radius:12px; padding:16px 18px; border:1px solid #f3f4f6;">
        <div style="font-size:15px; font-weight:600; color:#1f2937; margin-bottom:4px;">💻 开发者常用英文术语</div>
        <div style="font-size:13px; color:#6b7280; line-height:1.6;">API、async、frontend、pull request、code review —— Vibe Coding 中常说的英文术语都在词库里。</div>
      </div>
      <div style="background:#f9fafb; border-radius:12px; padding:16px 18px; border:1px solid #f3f4f6;">
        <div style="font-size:15px; font-weight:600; color:#1f2937; margin-bottom:4px;">✏️ 支持自定义扩展</div>
        <div style="font-size:13px; color:#6b7280; line-height:1.6;" id="s4-dict-note">你可以随时在菜单栏添加自己的专属词汇 —— 公司名、项目名、同事英文名，说什么识别什么。</div>
      </div>
    </div>
  </div>
  <div class="step-nav">
    <button class="btn btn-ghost" onclick="goBack()" id="nav-back-5">返回</button>
    <button class="btn btn-primary" onclick="doFinish()" id="nav-next-5">开始使用虾BB</button>
  </div>
</div>

<!-- STEP 5: DICTIONARY -->
<div class="step" id="step-4">
  <div class="step-header">
    <div class="app-icon app-icon-sm"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="#000000"><g transform="translate(0,512) scale(0.1,-0.1)" stroke="none"><path d="M1966 4593 c-52 -19 -61 -29 -47 -51 6 -11 16 -11 47 -3 179 50 341 -76 368 -284 l6 -50 -53 -29 c-123 -70 -241 -239 -254 -369 l-6 -57 150 0 c133 0 152 -2 166 -18 21 -23 22 -46 2 -66 -12 -12 -43 -15 -153 -15 -75 1 -143 0 -149 0 -8 -1 -13 -17 -13 -46 l0 -44 156 -3 c134 -3 158 -5 167 -20 8 -12 8 -24 -1 -42 l-12 -26 -155 0 -155 0 0 -45 0 -45 153 0 c161 0 177 -4 177 -45 0 -41 -16 -45 -177 -45 l-153 0 0 -45 0 -45 153 0 c161 0 177 -4 177 -45 0 -41 -16 -45 -177 -45 l-153 0 0 -40 0 -40 83 -1 c268 -4 973 4 980 11 5 4 7 20 5 36 l-3 29 -158 5 c-125 4 -160 8 -167 20 -13 20 -12 33 3 53 10 14 36 17 167 19 l155 3 0 40 0 40 -155 3 c-131 2 -157 5 -167 19 -17 22 -16 38 3 57 13 13 42 16 165 16 l149 0 6 24 c3 14 3 34 -1 45 -6 20 -13 21 -155 21 -147 0 -150 0 -165 24 -13 19 -14 29 -5 45 10 20 19 21 165 21 l155 0 0 45 0 45 -145 0 c-132 0 -147 2 -165 20 -11 11 -20 25 -20 30 0 6 9 19 20 30 18 18 33 20 170 20 171 0 161 -7 135 95 -34 128 -117 243 -232 317 l-65 43 5 50 c13 122 81 226 180 274 42 20 58 23 120 18 40 -3 81 -7 92 -9 14 -2 21 3 23 19 3 19 -5 24 -49 37 -66 20 -102 20 -165 1 -68 -20 -140 -69 -180 -124 -39 -54 -74 -149 -74 -204 l0 -39 -52 9 c-67 10 -201 10 -253 0 l-41 -8 -11 63 c-15 88 -49 154 -110 216 -88 89 -205 121 -307 85z"/><path d="M1787 2982 c-13 -14 -17 -39 -17 -110 0 -87 1 -92 25 -108 l25 -16 0 -347 c0 -365 5 -410 50 -515 58 -137 181 -267 319 -340 72 -38 197 -76 248 -76 l33 0 0 -273 0 -274 -132 -7 c-198 -9 -394 -35 -468 -59 -56 -19 -65 -25 -65 -46 0 -30 41 -45 170 -66 251 -40 825 -45 1095 -11 142 18 260 50 260 70 0 35 -79 66 -220 85 -99 14 -317 31 -396 31 l-64 0 0 274 0 273 55 7 c290 34 541 271 594 560 7 35 11 196 11 390 0 320 1 332 20 344 18 11 20 23 20 112 0 114 -4 120 -85 120 -81 0 -85 -6 -85 -120 0 -89 2 -101 20 -112 19 -12 20 -23 20 -333 -1 -358 -9 -433 -58 -536 -67 -140 -194 -253 -342 -305 -100 -34 -323 -45 -440 -20 -221 46 -400 216 -455 431 -12 47 -15 128 -15 407 0 320 1 348 18 357 14 8 17 24 17 104 0 117 -6 127 -85 127 -42 0 -61 -5 -73 -18z"/><path d="M2030 2600 c0 -318 1 -345 21 -403 40 -121 116 -218 223 -286 166 -104 344 -115 523 -31 81 37 190 140 231 218 59 112 62 133 62 504 l0 338 -530 0 -530 0 0 -340z"/><path d="M1802 712 c3 -36 5 -38 70 -59 132 -44 282 -57 673 -58 362 0 492 8 655 41 108 23 130 36 130 80 0 31 -3 35 -17 29 -142 -59 -595 -94 -958 -75 -240 12 -409 32 -496 60 l-60 18 3 -36z"/></g></svg></div>
    <div class="step-bar" id="dots-4" style="flex:1; padding:0; min-height:unset;"></div>
  </div>
  <!-- TWO-COLUMN TRY IT LAYOUT -->
  <div id="s5-body" style="display:flex; flex:1; flex-direction:row; overflow:hidden; padding:12px 20px 0;">
    <!-- LEFT COLUMN (55%) -->
    <div style="flex:0 0 55%; display:flex; flex-direction:column; padding-right:16px; overflow-y:auto;">
      <h2 id="s5-title" style="margin:0 0 6px; font-size:22px;">试试看</h2>
      <p id="s5-instruction" style="margin:0 0 12px; font-size:14px; color:var(--gray-500);">按住 Globe 键，念出下面这句话：</p>
      <!-- Quote card -->
      <div style="border-left:3px solid var(--blue); background:var(--blue-light); border-radius:0 10px 10px 0; padding:12px 14px; margin-bottom:14px;">
        <div style="font-size:13px; color:var(--blue-dark); font-weight:500; line-height:1.8; font-style:italic;" id="s5-sample-sentence">"无论是用 Claude Code 还是 OpenClaw，都能够成为一个出色的 Vibe Coder，享受 AI Agent 时代的无限自由。"</div>
      </div>
      <!-- Mic hint -->
      <div style="background:#f3f4f6; border-radius:8px; padding:8px 12px; margin-bottom:14px; display:flex; align-items:flex-start; gap:8px;">
        <span style="font-size:16px; line-height:1.4;">🎙</span>
        <span id="s5-press-hint" style="font-size:13px; color:var(--gray-500); line-height:1.5;">按住 Globe 键开始说话，松开后文字会出现在右边</span>
      </div>
      <!-- Status indicator -->
      <div id="s5-status" style="display:flex; align-items:center; gap:8px; min-height:28px;">
        <span id="s5-status-dot" style="width:10px; height:10px; border-radius:50%; background:#d1d5db; flex-shrink:0;"></span>
        <span id="s5-status-label" style="font-size:13px; color:var(--gray-500);">等待录音…</span>
      </div>
      <!-- Success note (hidden until transcription arrives) -->
      <div id="s5-done-note-box" style="display:none; background:var(--green-light); border:1.5px solid #bbf7d0; border-radius:10px; padding:10px 14px; margin-top:12px;">
        <p style="font-size:13px; color:#15803d; line-height:1.6; margin:0;" id="s5-done-note">注意到了吗？Claude Code、OpenClaw、Vibe Coder 都被精确识别了。</p>
      </div>
    </div>
    <!-- RIGHT COLUMN (45%) -->
    <div style="flex:0 0 45%; display:flex; flex-direction:column;">
      <!-- Mock window -->
      <div class="mock-window" style="flex:1; display:flex; flex-direction:column; min-height:0;">
        <div class="mock-titlebar">
          <div class="mock-dot" style="background:#ff5f57;"></div>
          <div class="mock-dot" style="background:#febc2e;"></div>
          <div class="mock-dot" style="background:#28c840;"></div>
          <span style="margin-left:6px; font-size:12px; color:#6b7280; font-weight:500;">虾BB</span>
        </div>
        <div class="mock-body" id="s5-mock-text" style="flex:1; overflow-y:auto;">
          <span id="s5-mock-placeholder" style="color:#9ca3af; font-style:italic;">你的语音会出现在这里...</span>
        </div>
      </div>
    </div>
  </div>
  <div class="step-nav" id="s5-nav" style="padding-top:8px;">
    <button class="btn btn-ghost" onclick="goBack()" id="nav-back-4">返回</button>
    <div style="display:flex; gap:10px;">
      <button class="btn btn-ghost" onclick="goNext()" id="s5-skip-btn" style="font-size:13px; color:#9ca3af;">跳过</button>
      <button class="btn btn-primary" onclick="goNext()" id="s5-start-btn" disabled style="opacity:0.4; cursor:not-allowed;">下一步</button>
    </div>
  </div>
</div>

<script>
var currentStep = 0;
var lang = 'zh';
var keyValidated = false;
var micConfirmed = false;
var barLevels = new Array(12).fill(0);

var S = {
  zh: {
    s0_title:'欢迎使用虾BB', s0_desc:'专为 Vibe Coding 打造的语音输入法',
    s0_btn:'开始设置', s0_hint:'设置只需 2 分钟',
    s1_title:'配置 API Key', s1_desc:'虾BB 使用 Google Gemini 进行语音识别。免费额度：每天 250 次，无需信用卡。',
    s1_hint:'免费版每天 250 次请求', s1_placeholder:'粘贴 API Key...',
    s1_validate:'验证', s1_validating:'验证中...', s1_existing:'已配置 Key', s1_change:'更换',
    s2_title:'麦克风权限', s2_desc:'虾BB 需要麦克风权限来录制你的声音。',
    s2_card_title:'麦克风', s2_granted:'已授权', s2_pending:'等待授权...',
    s2_grant_btn:'允许', s2_q:'说话时你能看到音量条在动吗？', s2_yes:'可以，继续', s2_no:'不行，换麦克风',
    s3_title:'辅助功能权限', s3_desc:'虾BB 需要辅助功能权限来检测 Globe 键（地球键）。授权后可能需要重启。',
    s3_card_title:'辅助功能', s3_granted:'已授权', s3_pending:'等待授权...',
    s3_grant_btn:'允许', s3_test_prompt:'按下 Globe 键（地球键）测试', s3_detected:'检测到 Globe 键！',
    s3_restart_note:'授权后若检测不到按键，可能需要重启虾BB。', s3_restart_btn:'重启',
    s4_title:'📖 智能词汇表', s4_desc:'虾BB 预置了 Vibe Coding 常用词汇，确保精准识别：',
    s4_dict_note:'你可以随时在菜单栏 → 自定义词汇表中添加更多词汇。',
    s5_title:'试试看', s5_instruction:'按住 Globe 键，念出下面这句话：',
    s5_sample:'"无论是用 Claude Code 还是 OpenClaw，都能够成为一个出色的 Vibe Coder，享受 AI Agent 时代的无限自由。"',
    s5_press_hint:'按住 Globe 键开始说话，松开后文字会出现在右边',
    s5_done_note:'注意到了吗？Claude Code、OpenClaw、Vibe Coder 都被精确识别了。',
    s5_start:'开始使用虾BB',
    s5_status_idle:'等待录音…',
    s5_status_recording:'🔴 录音中…',
    s5_status_processing:'⏳ 识别中…',
    s5_status_done:'✅ 识别完成！',
    chk_key:'API Key', chk_key_ok:'已配置', chk_key_no:'未配置',
    chk_mic:'麦克风',  chk_mic_ok:'已授权', chk_mic_no:'未授权',
    chk_acc:'辅助功能',chk_acc_ok:'已授权', chk_acc_no:'未授权',
    back:'返回', next:'下一步', key_missing:'请输入 Key'
  },
  en: {
    s0_title:'Welcome to XiaBB', s0_desc:'Voice-to-text built for Vibe Coding',
    s0_btn:'Get Started', s0_hint:'Setup takes about 2 minutes',
    s1_title:'Gemini API Key', s1_desc:'XiaBB uses Google Gemini for transcription. Free tier: 250 requests/day, no credit card.',
    s1_hint:'Free tier: 250 requests per day', s1_placeholder:'Paste API Key...',
    s1_validate:'Validate', s1_validating:'Validating...', s1_existing:'Key configured', s1_change:'Change',
    s2_title:'Microphone Access', s2_desc:'XiaBB needs microphone access to capture your voice.',
    s2_card_title:'Microphone', s2_granted:'Granted', s2_pending:'Waiting...',
    s2_grant_btn:'Allow', s2_q:'Can you see the bars moving when you speak?', s2_yes:'Yes, continue', s2_no:'No, switch mic',
    s3_title:'Accessibility (Globe Key)', s3_desc:'XiaBB needs Accessibility to detect the Globe / fn key. A restart may be needed.',
    s3_card_title:'Accessibility', s3_granted:'Granted', s3_pending:'Waiting...',
    s3_grant_btn:'Allow', s3_test_prompt:'Press the Globe key to test', s3_detected:'Globe key detected!',
    s3_restart_note:'If the key is not detected after granting, restart XiaBB.', s3_restart_btn:'Restart',
    s4_title:'📖 Smart Dictionary', s4_desc:'XiaBB includes preset Vibe Coding vocabulary for accurate recognition:',
    s4_dict_note:'You can add more words anytime via menu bar → Custom Dictionary.',
    s5_title:'Try It Out', s5_instruction:'Hold Globe key and read the sentence below:',
    s5_sample:'"Whether using Claude Code or OpenClaw, you can become an outstanding Vibe Coder and enjoy the boundless freedom of the AI Agent era."',
    s5_press_hint:'Hold Globe key to start speaking — text will appear on the right when done',
    s5_done_note:'Notice how Claude Code, OpenClaw, Vibe Coder were all perfectly recognized.',
    s5_start:'Start Using XiaBB',
    s5_status_idle:'Waiting to record…',
    s5_status_recording:'🔴 Recording…',
    s5_status_processing:'⏳ Transcribing…',
    s5_status_done:'✅ Transcription complete!',
    chk_key:'API Key', chk_key_ok:'Configured', chk_key_no:'Missing',
    chk_mic:'Microphone', chk_mic_ok:'Granted', chk_mic_no:'Not granted',
    chk_acc:'Accessibility', chk_acc_ok:'Granted', chk_acc_no:'Not granted',
    back:'Back', next:'Next', key_missing:'Please enter a key'
  }
};
function t(k) { return (S[lang] && S[lang][k]) || (S.en[k]) || k; }

function setText(id, val) { var el = document.getElementById(id); if (el) el.textContent = val; }
function setPlaceholder(id, val) { var el = document.getElementById(id); if (el) el.placeholder = val; }
function setDisabled(id, v) { var el = document.getElementById(id); if (el) el.disabled = v; }

function applyLang() {
  setText('s0-title', t('s0_title')); setText('s0-desc', t('s0_desc'));
  setText('s0-btn', t('s0_btn')); setText('s0-hint', t('s0_hint'));
  setText('s1-title', t('s1_title')); setText('s1-desc', t('s1_desc'));
  setText('s1-hint', t('s1_hint')); setPlaceholder('apiKeyInput', t('s1_placeholder'));
  setText('validateBtn', t('s1_validate'));
  setText('s1-existing-label', t('s1_existing')); setText('s1-change-btn', t('s1_change'));
  setText('s2-title', t('s2_title')); setText('s2-desc', t('s2_desc'));
  setText('s2-card-title', t('s2_card_title')); setText('s2-status-desc', t('s2_pending'));
  setText('s2-grant-btn', t('s2_grant_btn')); setText('s2-q', t('s2_q'));
  setText('s2-yes-btn', t('s2_yes')); setText('s2-no-btn', t('s2_no'));
  setText('s3-title', t('s3_title')); setText('s3-desc', t('s3_desc'));
  setText('s3-card-title', t('s3_card_title')); setText('s3-status-desc', t('s3_pending'));
  setText('s3-grant-btn', t('s3_grant_btn')); setText('s3-test-prompt', t('s3_test_prompt'));
  setText('s3-restart-note', t('s3_restart_note')); setText('s3-restart-btn', t('s3_restart_btn'));
  setText('s4-title', t('s4_title')); setText('s4-desc', t('s4_desc'));
  setText('s4-dict-note', t('s4_dict_note'));
  setText('s5-title', t('s5_title')); setText('s5-instruction', t('s5_instruction'));
  setText('s5-sample-sentence', t('s5_sample')); setText('s5-press-hint', t('s5_press_hint'));
  setText('s5-done-note', t('s5_done_note'));
  setText('s5-start-btn', t('next'));
  setText('s5-status-label', t('s5_status_idle'));
  ['1','2','3','4'].forEach(function(n) {
    setText('nav-back-'+n, t('back'));
    setText('nav-next-'+n, t('next'));
  });
  setText('nav-back-5', t('back'));
  setText('nav-next-5', t('s5_start'));
}

function renderDots(stepEl, stepNum) {
  var container = stepEl.querySelector('.step-bar');
  if (!container) return;
  while (container.firstChild) container.removeChild(container.firstChild);
  for (var i = 1; i <= 5; i++) {
    var d = document.createElement('div');
    d.className = 'step-dot' + (i < stepNum ? ' done' : i === stepNum ? ' active' : '');
    container.appendChild(d);
  }
}

function showStep(n) {
  var steps = document.querySelectorAll('.step');
  for (var i = 0; i < steps.length; i++) steps[i].classList.remove('active','fade-in');
  var el = document.getElementById('step-'+n);
  if (!el) return;
  el.classList.add('active');
  if (n >= 1 && n <= 5) renderDots(el, n);
  void el.offsetWidth;
  el.classList.add('fade-in');
  currentStep = n;
  if (n === 2) {
    window.webkit && window.webkit.messageHandlers.xiabb.postMessage({action:'startMicTest'});
  } else {
    window.webkit && window.webkit.messageHandlers.xiabb.postMessage({action:'stopMicTest'});
  }
}

function startSetup() { showStep(1); }
function goBack() { if (currentStep > 0) showStep(currentStep - 1); }
function goNext() {
  if (currentStep < 5) showStep(currentStep + 1);
  else doFinish();
}

function onKeyInput() {
  keyValidated = false;
  setDisabled('nav-next-1', true);
  var input = document.getElementById('apiKeyInput');
  if (input) input.className = 'input-field';
  clearKeyStatus();
}
function onKeyDown(e) { if (e.key === 'Enter') doValidate(); }
function openAIStudio() {
  window.webkit && window.webkit.messageHandlers.xiabb.postMessage({action:'openURL', url:'https://aistudio.google.com/apikey'});
}
function changeKey() {
  var ex = document.getElementById('s1-existing');
  var ia = document.getElementById('s1-input-area');
  if (ex) ex.style.display = 'none';
  if (ia) ia.style.display = '';
  keyValidated = false;
  setDisabled('nav-next-1', true);
}
function clearKeyStatus() {
  setText('keyStatusIcon', '');
  setText('keyStatusText', '');
  var sp = document.getElementById('keyStatusSpinner');
  if (sp) sp.style.display = 'none';
}
function setKeyStatus(icon, text, color, showSpinner) {
  setText('keyStatusIcon', icon);
  setText('keyStatusText', text);
  var ti = document.getElementById('keyStatusText');
  if (ti) ti.style.color = color || '';
  var sp = document.getElementById('keyStatusSpinner');
  if (sp) sp.style.display = showSpinner ? 'inline-block' : 'none';
}
function doValidate() {
  var input = document.getElementById('apiKeyInput');
  var key = input ? input.value.trim() : '';
  if (!key) {
    setKeyStatus('', t('key_missing'), 'var(--red)', false);
    return;
  }
  setDisabled('validateBtn', true);
  setText('validateBtn', t('s1_validating'));
  setKeyStatus('', t('s1_validating'), 'var(--blue-dark)', true);
  window.webkit && window.webkit.messageHandlers.xiabb.postMessage({action:'validateKey', key:key});
}
function onKeyValidated(ok, msg) {
  setDisabled('validateBtn', false);
  setText('validateBtn', t('s1_validate'));
  var input = document.getElementById('apiKeyInput');
  if (ok) {
    keyValidated = true;
    if (input) input.className = 'input-field success';
    setKeyStatus('✅', msg, 'var(--green)', false);
    setDisabled('nav-next-1', false);
  } else {
    keyValidated = false;
    if (input) input.className = 'input-field error';
    setKeyStatus('❌', msg, 'var(--red)', false);
    setDisabled('nav-next-1', true);
  }
}
function showExistingKey(masked) {
  var ex = document.getElementById('s1-existing');
  var ia = document.getElementById('s1-input-area');
  var ms = document.getElementById('s1-existing-masked');
  if (ex) ex.style.display = '';
  if (ia) ia.style.display = 'none';
  if (ms) ms.textContent = masked;
  keyValidated = true;
  setDisabled('nav-next-1', false);
}

function doRequestMic() {
  window.webkit && window.webkit.messageHandlers.xiabb.postMessage({action:'requestMic'});
}
function onMicPermission(granted) {
  var desc = document.getElementById('s2-status-desc');
  var area = document.getElementById('s2-grant-area');
  if (granted) {
    if (desc) { desc.textContent = '✅ ' + t('s2_granted'); desc.style.color = 'var(--green)'; }
    while (area && area.firstChild) area.removeChild(area.firstChild);
    if (area) {
      var badge = document.createElement('span');
      badge.className = 'badge badge-success';
      badge.textContent = '✅ ' + t('s2_granted');
      area.appendChild(badge);
    }
  } else {
    if (desc) { desc.textContent = t('s2_pending'); desc.style.color = ''; }
  }
}
function onMicLevel(level) {
  // Horizontal level meter: bars 0-11 left to right
  // Number of active bars based on level
  var activeBars = Math.round(level * 12);
  for (var i = 0; i < 12; i++) {
    var isActive = i < activeBars;
    // Each bar gets a slight random variation in height for organic look
    var noise = isActive ? (0.6 + 0.4 * Math.random()) : (0.15 + 0.15 * Math.random());
    // Bars towards center are taller (bell-curve shape)
    var shape = 1 - Math.abs(i - 5.5) / 6;
    var targetH = isActive
      ? Math.max(8, Math.round((14 + shape * 30) * noise * (level * 1.2 + 0.3)))
      : Math.max(4, Math.round(6 * noise));
    barLevels[i] = barLevels[i] * 0.55 + targetH * 0.45;
    var bar = document.getElementById('bar'+i);
    if (bar) {
      bar.style.height = Math.round(barLevels[i]) + 'px';
      if (isActive) bar.classList.add('active');
      else bar.classList.remove('active');
    }
  }
}
function micYes() {
  micConfirmed = true;
  setDisabled('nav-next-2', false);
  showStep(3);
}
function micNo() {
  window.webkit && window.webkit.messageHandlers.xiabb.postMessage({action:'openURL', url:'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone'});
}
function updatePermissionStatus(perm, granted) {
  if (perm === 'mic') {
    onMicPermission(granted);
    if (granted) window.webkit && window.webkit.messageHandlers.xiabb.postMessage({action:'startMicTest'});
  } else if (perm === 'acc') {
    onAccessibilityGranted();
  }
}

function doRequestAcc() {
  window.webkit && window.webkit.messageHandlers.xiabb.postMessage({action:'requestAccessibility'});
}
function onAccessibilityGranted() {
  var desc = document.getElementById('s3-status-desc');
  var area = document.getElementById('s3-grant-area');
  if (desc) { desc.textContent = '✅ ' + t('s3_granted'); desc.style.color = 'var(--green)'; }
  while (area && area.firstChild) area.removeChild(area.firstChild);
  if (area) {
    var badge = document.createElement('span');
    badge.className = 'badge badge-success';
    badge.textContent = '✅ ' + t('s3_granted');
    area.appendChild(badge);
  }
}
var globeDetectedOnce = false;
function onGlobeKeyDetected() {
  // Legacy: treat as a press event
  onGlobeKeyDown();
  setTimeout(onGlobeKeyUp, 200);
}
function onGlobeKeyDown() {
  var key = document.getElementById('globeKey');
  if (key) {
    key.classList.add('pressed');
  }
  // Mark first detection for step 3
  if (!globeDetectedOnce) {
    globeDetectedOnce = true;
    var lbl = document.getElementById('s3-detected-label');
    if (lbl) { lbl.textContent = '✅ ' + t('s3_detected'); lbl.style.color = 'var(--blue)'; lbl.style.fontWeight = '500'; }
  }
  // Step 5: update status indicator to "recording"
  if (currentStep === 5) {
    setS5Status('recording');
  }
}
function onGlobeKeyUp() {
  var key = document.getElementById('globeKey');
  if (key) {
    key.classList.remove('pressed');
  }
  // Step 5: update status indicator to "processing"
  if (currentStep === 5) {
    setS5Status('processing');
  }
}
function setS5Status(state) {
  var dot = document.getElementById('s5-status-dot');
  var lbl = document.getElementById('s5-status-label');
  if (!dot || !lbl) return;
  if (state === 'recording') {
    dot.style.background = '#ef4444';
    dot.style.boxShadow = '0 0 0 3px rgba(239,68,68,0.25)';
    lbl.textContent = t('s5_status_recording');
    lbl.style.color = '#ef4444';
  } else if (state === 'processing') {
    dot.style.background = '#f59e0b';
    dot.style.boxShadow = '0 0 0 3px rgba(245,158,11,0.2)';
    lbl.textContent = t('s5_status_processing');
    lbl.style.color = '#92400e';
  } else if (state === 'done') {
    dot.style.background = '#22c55e';
    dot.style.boxShadow = '0 0 0 3px rgba(34,197,94,0.2)';
    lbl.textContent = t('s5_status_done');
    lbl.style.color = '#15803d';
  } else {
    dot.style.background = '#d1d5db';
    dot.style.boxShadow = 'none';
    lbl.textContent = t('s5_status_idle');
    lbl.style.color = 'var(--gray-500)';
  }
}
function onTranscriptionResult(text) {
  // Show text in mock window with a fade-in effect
  var mockBody = document.getElementById('s5-mock-text');
  if (mockBody) {
    mockBody.innerHTML = '';
    var span = document.createElement('span');
    span.textContent = text;
    span.style.opacity = '0';
    span.style.transition = 'opacity 0.4s ease';
    mockBody.appendChild(span);
    // Trigger fade-in
    setTimeout(function() { span.style.opacity = '1'; }, 30);
  }
  // Show success note
  var noteBox = document.getElementById('s5-done-note-box');
  if (noteBox) noteBox.style.display = 'block';
  // Update status
  setS5Status('done');
  // Enable finish button
  var btn = document.getElementById('s5-start-btn');
  if (btn) {
    btn.disabled = false;
    btn.style.opacity = '1';
    btn.style.cursor = 'pointer';
  }
}
function doRestart() {
  window.webkit && window.webkit.messageHandlers.xiabb.postMessage({action:'restart'});
}
function doFinish() {
  window.webkit && window.webkit.messageHandlers.xiabb.postMessage({action:'finish'});
}

function updateChecklist(hasKey, micOK, accOK) {
  function setItem(iconId, labelId, statusId, ok, okLabel, noLabel) {
    setText(iconId, ok ? '✅' : '⚠️');
    setText(labelId, ok ? okLabel : noLabel);
    var st = document.getElementById(statusId);
    if (st) { st.textContent = ok ? t(okLabel.replace ? okLabel : okLabel) : noLabel; st.style.color = ok ? 'var(--green)' : 'var(--gray-400)'; }
  }
  // simpler: just set text directly
  setText('chk-key-icon', hasKey ? '✅' : '⚠️');
  setText('chk-key-status', hasKey ? t('chk_key_ok') : t('chk_key_no'));
  var ks = document.getElementById('chk-key-status');
  if (ks) ks.style.color = hasKey ? 'var(--green)' : 'var(--gray-400)';

  setText('chk-mic-icon', micOK ? '✅' : '⚠️');
  setText('chk-mic-status', micOK ? t('chk_mic_ok') : t('chk_mic_no'));
  var ms = document.getElementById('chk-mic-status');
  if (ms) ms.style.color = micOK ? 'var(--green)' : 'var(--gray-400)';

  setText('chk-acc-icon', accOK ? '✅' : '⚠️');
  setText('chk-acc-status', accOK ? t('chk_acc_ok') : t('chk_acc_no'));
  var as = document.getElementById('chk-acc-status');
  if (as) as.style.color = accOK ? 'var(--green)' : 'var(--gray-400)';
}

function init(cfg) {

  lang = cfg.lang || 'zh';
  applyLang();
  showStep(cfg.step || 0);
  if (cfg.existingKey) showExistingKey(cfg.existingKey);
  if (cfg.micGranted) onMicPermission(true);
  if (cfg.accGranted) onAccessibilityGranted();
  updateChecklist(cfg.existingKey, cfg.micGranted, cfg.accGranted);
}
</script>
</body>
</html>
"""

// MARK: - OnboardingWindow class

class OnboardingWindow: NSObject, WKScriptMessageHandler {
    private var window: NSWindow?
    var webView: WKWebView?

    // Mic test engine
    private var micEngine: AVAudioEngine?
    private var micTestActive = false

    // Globe key monitor (separate from main event tap)
    private var globeMonitor: Any?

    func show() {
        // Show in Dock + Cmd+Tab during onboarding
        NSApp.setActivationPolicy(.regular)
        // Ensure Globe key events route to onboarding
        if let app = XiaBBApp.shared { app.isOnboarding = true }

        if let w = window, w.isVisible {
            w.makeKeyAndOrderFront(nil)
            NSApp.activate(ignoringOtherApps: true)
            return
        }

        let savedStep = loadConfig()["onboarding_step"] as? Int ?? 0

        let wW: CGFloat = 600, wH: CGFloat = 550
        let win = NSWindow(
            contentRect: NSRect(x: 0, y: 0, width: wW, height: wH),
            styleMask: [.titled, .closable],
            backing: .buffered,
            defer: false
        )
        win.title = currentLang == "zh" ? "虾BB — 设置向导" : "XiaBB — Setup Wizard"
        win.center()
        win.isReleasedWhenClosed = false
        win.styleMask.remove(.resizable)
        self.window = win

        // Build WKWebView
        let config = WKWebViewConfiguration()
        config.userContentController.add(self, name: "xiabb")

        let wv = WKWebView(frame: NSRect(x: 0, y: 0, width: wW, height: wH), configuration: config)
        wv.autoresizingMask = [.width, .height]
        wv.setValue(false, forKey: "drawsBackground")
        win.contentView = wv
        self.webView = wv

        wv.loadHTMLString(onboardingHTML, baseURL: nil)

        win.makeKeyAndOrderFront(nil)
        NSApp.activate(ignoringOtherApps: true)

        // After load, call init() in JS with current state
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak self] in
            self?.sendInitToJS(step: savedStep)
        }
    }

    // MARK: - JS to Swift bridge

    func userContentController(_ userContentController: WKUserContentController,
                                didReceive message: WKScriptMessage) {
        guard let body = message.body as? [String: Any],
              let action = body["action"] as? String else { return }

        switch action {
        case "validateKey":
            let key = body["key"] as? String ?? ""
            validateAPIKey(key)

        case "requestMic":
            let status = AVCaptureDevice.authorizationStatus(for: .audio)
            if status == .notDetermined {
                AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
                    DispatchQueue.main.async {
                        let js = "updatePermissionStatus('mic', \(granted ? "true" : "false"))"
                        self?.webView?.evaluateJavaScript(js, completionHandler: nil)
                    }
                }
            } else {
                NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone")!)
            }

        case "requestAccessibility":
            let opts = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
            AXIsProcessTrustedWithOptions(opts)
            DistributedNotificationCenter.default().addObserver(
                forName: NSNotification.Name("com.apple.accessibility.api"),
                object: nil, queue: .main
            ) { [weak self] _ in
                if AXIsProcessTrusted() {
                    self?.webView?.evaluateJavaScript("onAccessibilityGranted()", completionHandler: nil)
                }
            }

        case "startMicTest":
            startMicTest()

        case "stopMicTest":
            stopMicTest()

        case "openURL":
            if let urlStr = body["url"] as? String, let url = URL(string: urlStr) {
                NSWorkspace.shared.open(url)
            }

        case "restart":
            let execPath = Bundle.main.executablePath ?? ProcessInfo.processInfo.arguments[0]
            let task = Process()
            task.executableURL = URL(fileURLWithPath: execPath)
            try? task.run()
            NSApp.terminate(nil)

        case "finish":
            finish()

        default:
            log("[onboarding] Unknown action: \(action)")
        }
    }

    // MARK: - Init JS

    private func sendInitToJS(step: Int) {
        let micGranted = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
        let accGranted = AXIsProcessTrusted()
        let hasKey = !apiKey.isEmpty

        var maskedKeyStr = "null"
        if hasKey {
            let masked = maskKey(apiKey)
            // Escape for JS string — masked key only contains alphanumeric + * chars, safe
            maskedKeyStr = "\"\(masked)\""
        }

        let js = """
        init({lang:'\(currentLang)',step:\(step),existingKey:\(maskedKeyStr),micGranted:\(micGranted),accGranted:\(accGranted)});
        """
        webView?.evaluateJavaScript(js, completionHandler: nil)

        if accGranted { installGlobeMonitor() }
    }

    // MARK: - API Key Validation

    private func validateAPIKey(_ key: String) {
        guard !key.isEmpty else {
            let msg = currentLang == "zh" ? "请输入 Key" : "Please enter a key"
            webView?.evaluateJavaScript("onKeyValidated(false,'\(msg)')", completionHandler: nil)
            return
        }

        guard let url = URL(string: "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent") else { return }
        var req = URLRequest(url: url)
        req.httpMethod = "POST"
        req.setValue("application/json", forHTTPHeaderField: "Content-Type")
        req.setValue(key, forHTTPHeaderField: "x-goog-api-key")
        req.timeoutInterval = 15
        let bodyObj: [String: Any] = [
            "contents": [["parts": [["text": "Say OK"]]]],
            "generationConfig": ["maxOutputTokens": 5]
        ]
        req.httpBody = try? JSONSerialization.data(withJSONObject: bodyObj)

        URLSession.shared.dataTask(with: req) { [weak self] data, response, error in
            DispatchQueue.main.async {
                guard let self = self else { return }
                let httpCode = (response as? HTTPURLResponse)?.statusCode ?? 0
                if error == nil && httpCode == 200 {
                    let keyFile = dataDir.appendingPathComponent(".api-key")
                    try? key.write(to: keyFile, atomically: true, encoding: .utf8)
                    apiKey = key
                    let msg = currentLang == "zh" ? "Key 有效" : "Key is valid"
                    self.webView?.evaluateJavaScript("onKeyValidated(true,'\(msg)')", completionHandler: nil)
                    log("🔑 API key validated and saved via onboarding")
                } else {
                    var msg = currentLang == "zh" ? "Key 无效，请检查后重试" : "Invalid key — check and try again"
                    if let data = data,
                       let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
                       let err = json["error"] as? [String: Any],
                       let errMsg = err["message"] as? String {
                        // Remove single quotes to keep JS string safe
                        let safe = errMsg.replacingOccurrences(of: "'", with: "").prefix(80)
                        msg = String(safe)
                    }
                    self.webView?.evaluateJavaScript("onKeyValidated(false,'\(msg)')", completionHandler: nil)
                }
            }
        }.resume()
    }

    // MARK: - Mic Test

    private func startMicTest() {
        guard !micTestActive else { return }
        guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else { return }
        stopMicTest()
        micTestActive = true

        let engine = AVAudioEngine()
        micEngine = engine
        let inputNode = engine.inputNode
        let hwFormat = inputNode.outputFormat(forBus: 0)
        guard hwFormat.sampleRate > 0 && hwFormat.channelCount > 0 else { return }

        inputNode.installTap(onBus: 0, bufferSize: 1024, format: hwFormat) { [weak self] buffer, _ in
            guard let self = self, self.micTestActive else { return }
            let channelData = buffer.floatChannelData?[0]
            let frameLength = Int(buffer.frameLength)
            guard let data = channelData, frameLength > 0 else { return }
            var rms: Float = 0
            for i in 0..<frameLength { rms += data[i] * data[i] }
            rms = sqrtf(rms / Float(frameLength))
            let dB = 20 * log10f(max(rms, 1e-7))
            let norm = max(0, min(1, (dB + 60) / 50))
            let levelStr = String(format: "%.3f", norm)
            DispatchQueue.main.async { [weak self] in
                self?.webView?.evaluateJavaScript("onMicLevel(\(levelStr))", completionHandler: nil)
            }
        }
        engine.prepare()
        try? engine.start()
        log("[onboarding] Mic test started")
    }

    private func stopMicTest() {
        micTestActive = false
        micEngine?.inputNode.removeTap(onBus: 0)
        micEngine?.stop()
        micEngine = nil
    }

    // MARK: - Globe Key Monitor

    private var globeWasDown = false

    private func installGlobeMonitor() {
        removeGlobeMonitor()
        guard AXIsProcessTrusted() else { return }
        globeMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.flagsChanged]) { [weak self] event in
            guard let self = self else { return }
            let fnNow = event.modifierFlags.contains(.function)
            if fnNow && !self.globeWasDown {
                self.globeWasDown = true
                DispatchQueue.main.async {
                    self.webView?.evaluateJavaScript("onGlobeKeyDown()", completionHandler: nil)
                }
            } else if !fnNow && self.globeWasDown {
                self.globeWasDown = false
                DispatchQueue.main.async {
                    self.webView?.evaluateJavaScript("onGlobeKeyUp()", completionHandler: nil)
                }
            }
        }
    }

    // Called from XiaBBApp.handleFlagsChanged when isOnboarding=true
    func onGlobeEvent(pressed: Bool) {
        if pressed {
            webView?.evaluateJavaScript("onGlobeKeyDown()", completionHandler: nil)
        } else {
            webView?.evaluateJavaScript("onGlobeKeyUp()", completionHandler: nil)
        }
    }

    // Legacy entry point — kept for compatibility
    func onGlobeDetectedFromEventTap() {
        onGlobeEvent(pressed: true)
    }

    private func removeGlobeMonitor() {
        if let m = globeMonitor { NSEvent.removeMonitor(m); globeMonitor = nil }
    }

    // MARK: - Finish

    private func finish() {
        saveConfig(["onboarded": true, "onboarding_step": 0])
        stopMicTest()
        removeGlobeMonitor()
        window?.close()
        // Hide from Dock + Cmd+Tab, back to menu bar mode
        NSApp.setActivationPolicy(.accessory)
        // Re-enable recording after onboarding
        if let app = XiaBBApp.shared {
            app.isOnboarding = false
        }
        log("✅ Onboarding complete")
    }

    // MARK: - Helpers

    private func maskKey(_ key: String) -> String {
        if key.count > 16 { return String(key.prefix(8)) + "****" + String(key.suffix(4)) }
        else if key.count > 4 { return String(key.prefix(4)) + "****" }
        return "****"
    }

}

// MARK: - HUD Overlay

class HUDOverlay {
    private var window: NSWindow!
    private var label: NSTextField!
    private var dot: NSView!
    private var bg: NSView!
    private var bars: [NSView] = []  // 6 bars: [left0,left1,left2, right0,right1,right2]
    private var doneCheck: NSTextField!
    private var hudIcon: NSImageView!
    private var leftB: NSTextField!
    private var rightB: NSTextField!
    private var copyBtn: NSButton!
    var resultText = ""
    private var hideTimer: Timer?
    private var pulsePhase: Double = 0
    var isPulsing = false
    var isProcessing = false  // true when finalizing (after recording stops)

    init() {
        let cfg = loadConfig()
        let w: CGFloat = 280, h: CGFloat = 48
        let screen = NSScreen.main?.frame ?? NSRect(x: 0, y: 0, width: 1920, height: 1080)
        let x = (cfg["hud_x"] as? CGFloat) ?? ((screen.width - w) / 2)
        let y = (cfg["hud_y"] as? CGFloat) ?? (screen.height - h - 60)

        window = NSWindow(contentRect: NSRect(x: x, y: y, width: w, height: h),
                          styleMask: .borderless, backing: .buffered, defer: false)
        window.level = .floating
        window.isOpaque = false
        window.backgroundColor = .clear
        window.isMovableByWindowBackground = true
        window.collectionBehavior = [.canJoinAllSpaces, .stationary]
        window.hasShadow = true

        let cv = NSView(frame: NSRect(x: 0, y: 0, width: w, height: h))
        window.contentView = cv

        // Rounded background
        bg = HUDBackground(frame: NSRect(x: 0, y: 0, width: w, height: h))
        cv.addSubview(bg)

        // === Brand area: icon centered, wave bars on both sides, BB for success ===
        let iconH: CGFloat = 36
        let iconW: CGFloat = iconH * (14.0 / 36.0)
        let brandCenterX: CGFloat = 24 // center of the brand area
        let iconView = NSImageView(frame: NSRect(x: brandCenterX - iconW / 2, y: (h - iconH) / 2, width: iconW, height: iconH))
        let iconPaths = [
            Bundle.main.resourceURL?.appendingPathComponent("[REDACTED_SECRET]"),
            dataDir.appendingPathComponent("[REDACTED_SECRET]"),
            Bundle.main.resourceURL?.appendingPathComponent("icon.png"),
            dataDir.appendingPathComponent("icon.png"),
        ].compactMap { $0 }
        for path in iconPaths {
            if let img = NSImage(contentsOf: path) {
                img.isTemplate = true
                iconView.image = img
                break
            }
        }
        iconView.imageScaling = .scaleProportionallyDown
        iconView.contentTintColor = .systemRed
        cv.addSubview(iconView)
        hudIcon = iconView

        // Wave bars — 3 on each side, short→medium→long outward
        let barW: CGFloat = 2
        let barGap: CGFloat = 2.5
        let barHeights: [CGFloat] = [6, 10, 14]
        bars = []

        for i in 0..<3 {
            let x = brandCenterX - iconW / 2 - CGFloat(i + 1) * (barW + barGap)
            let bh = barHeights[i]
            let bar = NSView(frame: NSRect(x: x, y: (h - bh) / 2, width: barW, height: bh))
            bar.wantsLayer = true
            bar.layer?.cornerRadius = barW / 2
            bar.layer?.backgroundColor = NSColor.systemRed.cgColor
            cv.addSubview(bar)
            bars.append(bar)
        }
        for i in 0..<3 {
            let x = brandCenterX + iconW / 2 + CGFloat(i) * (barW + barGap) + barGap
            let bh = barHeights[i]
            let bar = NSView(frame: NSRect(x: x, y: (h - bh) / 2, width: barW, height: bh))
            bar.wantsLayer = true
            bar.layer?.cornerRadius = barW / 2
            bar.layer?.backgroundColor = NSColor.systemRed.cgColor
            cv.addSubview(bar)
            bars.append(bar)
        }

        // "BB!" — super tiny, top-right of mic icon, like a little speech bubble saying hi
        let bbFont = NSFont(name: "Futura-Bold", size: 7) ?? .systemFont(ofSize: 7, weight: .black)
        let iconRight = brandCenterX + iconW / 2
        let iconTop = (h + iconH) / 2

        rightB = NSTextField(labelWithString: "BB!")
        rightB.font = bbFont
        rightB.textColor = .systemGreen
        rightB.backgroundColor = .clear
        rightB.isBezeled = false
        rightB.isEditable = false
        rightB.frame = NSRect(x: iconRight - 3, y: iconTop - 5, width: 20, height: 9)
        rightB.wantsLayer = true
        rightB.layer?.setAffineTransform(CGAffineTransform(rotationAngle: -0.25))
        rightB.isHidden = true
        cv.addSubview(rightB)

        leftB = rightB // alias to avoid crash on old references

        dot = iconView

        // Text label — starts after the brand area
        label = NSTextField(labelWithString: "")
        label.frame = NSRect(x: 52, y: (h - 20) / 2, width: w - 86, height: 20)
        label.textColor = currentTheme.textColor
        label.font = .systemFont(ofSize: currentTheme.fontSize, weight: .medium)
        label.backgroundColor = .clear
        label.isBezeled = false
        label.isEditable = false
        label.isSelectable = false
        label.lineBreakMode = .byTruncatingHead
        cv.addSubview(label)

        // Copy button — right side of HUD, hidden until result
        copyBtn = NSButton(frame: NSRect(x: w - 36, y: (h - 20) / 2, width: 28, height: 20))
        copyBtn.title = "📋"
        copyBtn.bezelStyle = .inline
        copyBtn.isBordered = false
        copyBtn.font = .systemFont(ofSize: 14)
        copyBtn.target = self
        copyBtn.action = #selector(handleClick)
        copyBtn.toolTip = "复制 / Copy"
        copyBtn.isHidden = true
        cv.addSubview(copyBtn)

        // Also keep whole-HUD click to copy
        let clickGR = NSClickGestureRecognizer(target: self, action: #selector(handleClick))
        window.contentView?.addGestureRecognizer(clickGR)
    }

    @objc private func handleClick() {
        guard !resultText.isEmpty else { return }
        NSPasteboard.general.clearContents()
        NSPasteboard.general.setString(resultText, forType: .string)
        label.stringValue = "✅ " + L("copied")
        copyBtn.title = "✅"
        // Hide after brief confirmation
        hideTimer?.invalidate()
        hideTimer = Timer.scheduledTimer(withTimeInterval: 0.8, repeats: false) { [weak self] _ in
            self?.hide()
        }
    }

    private func resize(for text: String) {
        let minW: CGFloat = 200, maxW: CGFloat = 700, h: CGFloat = 48
        let w = min(maxW, max(minW, CGFloat(text.count) * 7.5 + 56))
        let f = window.frame
        let cx = f.origin.x + f.width / 2
        let newX = cx - w / 2
        window.setFrame(NSRect(x: newX, y: f.origin.y, width: w, height: h), display: true)
        bg.frame = NSRect(x: 0, y: 0, width: w, height: h)
        bg.needsDisplay = true
        label.frame = NSRect(x: 52, y: (h - 20) / 2, width: w - 86, height: 20)
        copyBtn.frame = NSRect(x: w - 36, y: (h - 20) / 2, width: 28, height: 20)
    }

    func show(text: String) {
        DispatchQueue.main.async { [self] in
            resultText = ""
            hideTimer?.invalidate()
            hideTimer = nil
            resize(for: text)
            label.stringValue = text
            isProcessing = false
            let rc = currentTheme.recordingColor
            bars.forEach { $0.isHidden = false; $0.layer?.backgroundColor = rc.cgColor }
            rightB.isHidden = true
            copyBtn.isHidden = true
            hudIcon.contentTintColor = rc
            window.alphaValue = 1.0
            window.orderFrontRegardless()
            isPulsing = true
            pulsePhase = 0
        }
    }

    func updateText(_ text: String) {
        DispatchQueue.main.async { [self] in
            resize(for: text)
            label.stringValue = text
        }
    }

    func hide() {
        DispatchQueue.main.async { [self] in
            isPulsing = false
            window.orderOut(nil)
        }
    }

    func showResult(_ text: String, isError: Bool = false) {
        DispatchQueue.main.async { [self] in
            let display = isError ? "Error: \(text.prefix(50))" : String(text.prefix(60)) + (text.count > 60 ? "..." : "")
            if !isError { resultText = text }
            resize(for: display)
            label.stringValue = display
            isPulsing = false
            isProcessing = false
            // Hide wave bars, show B B! for success (or hide for error)
            bars.forEach { $0.isHidden = true }
            if isError {
                rightB.isHidden = true
            } else {
                // BB! pops out at upper-right of icon with spring bounce
                rightB.textColor = currentTheme.successColor
                rightB.isHidden = false
                rightB.alphaValue = 0
                rightB.layer?.setAffineTransform(
                    CGAffineTransform(scaleX: 0.1, y: 0.1).rotated(by: -0.2))

                // Pop in
                NSAnimationContext.runAnimationGroup { ctx in
                    ctx.duration = 0.25
                    ctx.timingFunction = CAMediaTimingFunction(name: .easeOut)
                    ctx.allowsImplicitAnimation = true
                    self.rightB.alphaValue = 1
                    self.rightB.layer?.setAffineTransform(
                        CGAffineTransform(scaleX: 1.3, y: 1.3).rotated(by: -0.2))
                }
                // Settle back
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
                    NSAnimationContext.runAnimationGroup { ctx in
                        ctx.duration = 0.2
                        ctx.allowsImplicitAnimation = true
                        self.rightB.layer?.setAffineTransform(
                            CGAffineTransform(scaleX: 1, y: 1).rotated(by: -0.2))
                    }
                }
            }
            hudIcon.contentTintColor = isError ? currentTheme.recordingColor : currentTheme.successColor
            copyBtn.isHidden = isError  // show copy button on success
            dot.alphaValue = 1.0
            window.alphaValue = 1.0
            window.orderFrontRegardless()

            hideTimer?.invalidate()
            hideTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) { [weak self] _ in
                self?.hide()
            }
        }
    }

    func tickPulse() {
        guard isPulsing else { return }
        pulsePhase += 0.15
        let h: CGFloat = 48
        let maxHeights: [CGFloat] = [6, 10, 14, 6, 10, 14]

        if isProcessing {
            for (i, bar) in bars.enumerated() {
                let idx = i % 3
                let delay = Double(2 - idx) * 3.0
                let t = pulsePhase * 2.5 - delay
                let spike = max(0, sin(t)) * exp(-0.3 * max(0, t.truncatingRemainder(dividingBy: .pi * 2)))
                let maxH: CGFloat = maxHeights[i]
                let barH = max(2, maxH * CGFloat(spike))
                bar.frame = NSRect(x: bar.frame.origin.x, y: (h - barH) / 2, width: bar.frame.width, height: barH)
            }
        } else {
            for (i, bar) in bars.enumerated() {
                let idx = i % 3
                let delay = Double(idx) * 3.0
                let t = pulsePhase * 2.0 - delay
                let spike = max(0, sin(t)) * exp(-0.2 * max(0, t.truncatingRemainder(dividingBy: .pi * 2)))
                let maxH: CGFloat = maxHeights[i]
                let barH = max(2, maxH * CGFloat(spike))
                bar.frame = NSRect(x: bar.frame.origin.x, y: (h - barH) / 2, width: bar.frame.width, height: barH)
            }
        }
    }
}

class HUDBackground: NSView {
    override func draw(_ dirtyRect: NSRect) {
        let t = currentTheme
        let path = NSBezierPath(roundedRect: bounds, xRadius: t.cornerRadius, yRadius: t.cornerRadius)
        NSColor(calibratedRed: t.bgRed, green: t.bgGreen, blue: t.bgBlue, alpha: t.bgAlpha).setFill()
        path.fill()
    }
}

// MARK: - App Controller

class XiaBBApp: NSObject {
    var statusItem: NSStatusItem!
    var statusMenuItem: NSMenuItem!
    var toggleItem: NSMenuItem!
    var iconIdle: NSImage?
    var iconRec: NSImage?
    var menuItems: [String: NSMenuItem] = [:]

    let recorder = AudioRecorder()
    let onboardingWindow = OnboardingWindow()
    var isOnboarding = false
    var lastTranscription = ""
    var liveSession: LiveSession?
    var hud: HUDOverlay!
    var liveSendTimer: Timer?
    var liveReconnectTimer: Timer?
    var liveAccumulatedText = "" // carries text across reconnections
    var lastSentChunk = 0
    var isTranscribing = false
    var tickCount = 0
    var idleTickCounter = 0

    var fnHeld = false
    var recordingStartTime: Date?
    var minRecordingDuration: TimeInterval = {
        // Configurable via .config.json "min_duration", default 2.0s
        return (loadConfig()["min_duration"] as? Double) ?? 2.0
    }()

    func setup() {
        let app = NSApplication.shared
        app.setActivationPolicy(.accessory)

        hud = HUDOverlay()

        // Load icons from bundle resources
        iconIdle = loadIcon(name: "icon", template: true)
        iconRec = loadIcon(name: "icon-red", template: false)
        if iconIdle == nil {
            iconIdle = NSImage(systemSymbolName: "mic.fill", accessibilityDescription: APP_NAME)
            iconIdle?.isTemplate = true
        }
        if iconRec == nil { iconRec = iconIdle }

        // Menu bar
        statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
        statusItem.button?.image = iconIdle
        statusItem.button?.toolTip = APP_NAME

        let menu = NSMenu()

        let title = NSMenuItem(title: "XiaBB 🦞", action: nil, keyEquivalent: "")
        title.isEnabled = false
        menu.addItem(title)

        statusMenuItem = NSMenuItem(title: "\(L("idle")) — \(usage.statusLine())", action: nil, keyEquivalent: "")
        statusMenuItem.isEnabled = false
        menu.addItem(statusMenuItem)

        menu.addItem(.separator())

        let hotkey = NSMenuItem(title: L("hotkey"), action: nil, keyEquivalent: "")
        hotkey.isEnabled = false
        menu.addItem(hotkey)
        menuItems["hotkey"] = hotkey

        menu.addItem(.separator())

        toggleItem = NSMenuItem(title: L("start"), action: #selector(toggleRecording), keyEquivalent: "")
        toggleItem.target = self
        menu.addItem(toggleItem)
        menuItems["toggle"] = toggleItem

        menu.addItem(.separator())

        let apiItem = NSMenuItem(title: L("configure_api"), action: #selector(configureAPI), keyEquivalent: "")
        apiItem.target = self
        menu.addItem(apiItem)
        menuItems["api"] = apiItem

        let dictItem = NSMenuItem(title: currentLang == "zh" ? "自定义词汇表..." : "Custom Dictionary...", action: #selector(editDictionary), keyEquivalent: "d")
        dictItem.target = self
        menu.addItem(dictItem)

        let copyLastItem = NSMenuItem(title: currentLang == "zh" ? "复制上一句" : "Copy Last Result", action: #selector(copyLastResult), keyEquivalent: "c")
        copyLastItem.target = self
        menu.addItem(copyLastItem)

        let launchItem = NSMenuItem(title: L("launch_login"), action: #selector(toggleLaunchAtLogin), keyEquivalent: "")
        launchItem.target = self
        launchItem.state = isLaunchAtLogin() ? .on : .off
        menu.addItem(launchItem)
        menuItems["launch"] = launchItem

        menu.addItem(.separator())

        let langItem = NSMenuItem(title: L("language"), action: nil, keyEquivalent: "")
        let langMenu = NSMenu()

        let enItem = NSMenuItem(title: "English", action: #selector(setLangEN), keyEquivalent: "")
        enItem.target = self
        enItem.state = currentLang == "en" ? .on : .off
        langMenu.addItem(enItem)
        menuItems["en"] = enItem

        let zhItem = NSMenuItem(title: "中文", action: #selector(setLangZH), keyEquivalent: "")
        zhItem.target = self
        zhItem.state = currentLang == "zh" ? .on : .off
        langMenu.addItem(zhItem)
        menuItems["zh"] = zhItem

        langItem.submenu = langMenu
        menu.addItem(langItem)
        menuItems["lang"] = langItem

        // Theme selector
        let themeItem = NSMenuItem(title: currentLang == "zh" ? "皮肤 / Theme" : "Theme", action: nil, keyEquivalent: "")
        let themeMenu = NSMenu()
        for (id, theme) in themeList {
            let item = NSMenuItem(title: theme.name, action: #selector(switchTheme(_:)), keyEquivalent: "")
            item.target = self
            item.representedObject = id
            item.state = (loadConfig()["theme"] as? String ?? "lobster") == id ? .on : .off
            themeMenu.addItem(item)
        }
        themeItem.submenu = themeMenu
        menu.addItem(themeItem)

        // Permissions / Settings
        let permItem = NSMenuItem(title: OB("perm_menu"), action: #selector(showPermissions), keyEquivalent: ",")
        permItem.target = self
        menu.addItem(permItem)
        menuItems["perm"] = permItem

        menu.addItem(.separator())

        let quitItem = NSMenuItem(title: L("quit"), action: #selector(quit), keyEquivalent: "q")
        quitItem.target = self
        menu.addItem(quitItem)
        menuItems["quit"] = quitItem

        statusItem.menu = menu

        // UI tick timer — fast when active, slow when idle
        Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
            guard let self = self else { return }
            // Skip ticks when idle to save CPU (only tick every 20th cycle = 1/sec)
            if !self.recorder.isRecording && !self.isTranscribing {
                self.idleTickCounter += 1
                if self.idleTickCounter % 20 != 0 { return }
            } else {
                self.idleTickCounter = 0
            }
            self.tick()
        }

        // Setup event tap for Globe key
        setupEventTap()

        log("🦞 \(APP_NAME) — Native Swift Voice-to-Text")
        log("   Model:  \(MODEL_REST) + \(MODEL_LIVE)")
        log("   Quota:  \(usage.statusLine())")
        log("   API key: \(apiKey.isEmpty ? "❌ MISSING" : "✅ set")")
        log("   Hotkey: 🌐 Globe (fn) — hold to speak")
        log("   HUD:    drag to reposition")
        log("✅ Ready!")

        // Request microphone permission on first launch
        // The system dialog appears when user first tries to record (AVAudioEngine.start)
        // For now just use AVCaptureDevice to pre-request — the real dialog
        // will appear on first Globe key press if this doesn't trigger it
        if AVCaptureDevice.authorizationStatus(for: .audio) == .notDetermined {
            AVCaptureDevice.requestAccess(for: .audio) { granted in
                log("🎤 Microphone: \(granted ? "granted" : "denied")")
            }
        }

        // Auto-show onboarding on first launch or if any permission is missing
        let hasSeenOnboarding = loadConfig()["onboarded"] as? Bool ?? false
        let missingPerms = !AXIsProcessTrusted()
            || AVCaptureDevice.authorizationStatus(for: .audio) != .authorized
            || apiKey.isEmpty
        if !hasSeenOnboarding || missingPerms {
            isOnboarding = true
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
                self?.onboardingWindow.show()
            }
        }
    }

    func loadIcon(name: String, template: Bool) -> NSImage? {
        // Try bundle resources first, then ~/Tools/xiabb/
        let paths = [
            Bundle.main.resourceURL?.appendingPathComponent("\(name)@2x.png"),
            Bundle.main.resourceURL?.appendingPathComponent("\(name).png"),
            dataDir.appendingPathComponent("\(name)@2x.png"),
            dataDir.appendingPathComponent("\(name).png"),
        ].compactMap { $0 }

        for path in paths {
            if let img = NSImage(contentsOf: path) {
                let sz = img.size
                if sz.height > 0 {
                    let scale = 18.0 / sz.height
                    img.size = NSSize(width: sz.width * scale, height: 18)
                }
                img.isTemplate = template
                return img
            }
        }
        return nil
    }

    // MARK: - Event Tap

    func setupEventTap() {
        log("Setting up event tap...")

        // Check accessibility permission first
        let trusted = AXIsProcessTrusted()
        log("AXIsProcessTrusted: \(trusted)")

        if !trusted {
            log("⚠️ Not trusted for accessibility — prompting user")

            // Trigger system accessibility permission prompt
            let opts = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
            AXIsProcessTrustedWithOptions(opts)

            // Listen for accessibility permission change via system notification (no polling)
            DistributedNotificationCenter.default().addObserver(
                forName: NSNotification.Name("com.apple.accessibility.api"),
                object: nil, queue: .main
            ) { [weak self] _ in
                if AXIsProcessTrusted() {
                    log("✅ Accessibility granted! Setting up event tap...")
                    self?.setupEventTapCore()
                    DistributedNotificationCenter.default().removeObserver(self as Any)
                }
            }

            log("   Listening for Accessibility permission change...")
            return
        }

        setupEventTapCore()
    }

    private var eventTap: CFMachPort?

    func setupEventTapCore() {
        let eventMask: CGEventMask = (1 << CGEventType.flagsChanged.rawValue)

        XiaBBApp.shared = self

        guard let tap = CGEvent.tapCreate(
            tap: .cgSessionEventTap,
            place: .headInsertEventTap,
            options: .listenOnly,
            eventsOfInterest: eventMask,
            callback: { proxy, type, event, refcon in
                // tapDisabledByTimeout — re-enable IMMEDIATELY
                if type == .tapDisabledByTimeout || type.rawValue == 0xFFFFFFFF {
                    if let tap = XiaBBApp.shared?.eventTap {
                        CGEvent.tapEnable(tap: tap, enable: true)
                        // Log on background to keep callback fast
                        DispatchQueue.global().async { log("⚠️ Event tap re-enabled (was disabled by timeout)") }
                    }
                    return Unmanaged.passUnretained(event)
                }
                // Handle Globe key — dispatch immediately, no work in callback
                XiaBBApp.shared?.handleFlagsChanged(event)
                return Unmanaged.passUnretained(event)
            },
            userInfo: nil
        ) else {
            log("❌ CGEvent.tapCreate returned nil (AXIsProcessTrusted=\(AXIsProcessTrusted()))")
            return
        }

        self.eventTap = tap
        let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
        CFRunLoopAddSource(CFRunLoopGetMain(), runLoopSource, .defaultMode)
        CGEvent.tapEnable(tap: tap, enable: true)
        log("✅ Event tap created and enabled")

        // Watchdog: re-enable event tap every 5 seconds in case it was silently disabled
        Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
            guard let tap = self?.eventTap else { return }
            if !CGEvent.tapIsEnabled(tap: tap) {
                CGEvent.tapEnable(tap: tap, enable: true)
                log("⚠️ Watchdog re-enabled event tap")
            }
        }
    }

    static var shared: XiaBBApp?

    // MUST be extremely fast — macOS disables the tap if callback is slow
    func handleFlagsChanged(_ event: CGEvent) {
        let fnNow = (event.flags.rawValue & FN_FLAG) != 0
        if fnNow && !fnHeld {
            fnHeld = true
            // Always start recording
            DispatchQueue.global(qos: .userInteractive).async { [weak self] in
                log("🌐 Globe DOWN")
                self?.startRecording()
            }
            // Also notify onboarding if active
            if isOnboarding {
                DispatchQueue.main.async { [weak self] in
                    self?.onboardingWindow.onGlobeEvent(pressed: true)
                }
            }
        } else if !fnNow && fnHeld {
            fnHeld = false
            // Always stop recording
            DispatchQueue.global(qos: .userInteractive).async { [weak self] in
                log("🌐 Globe UP")
                self?.stopRecording()
            }
            // Also notify onboarding if active
            if isOnboarding {
                DispatchQueue.main.async { [weak self] in
                    self?.onboardingWindow.onGlobeEvent(pressed: false)
                }
            }
        }
    }

    // MARK: - Recording

    func startRecording() {
        guard !recorder.isRecording else { return }
        log("🎙 Starting recording...")
        recordingStartTime = Date()

        playSound(sfxStart())

        let remaining = usage.remaining
        hud.show(text: L("listening"))

        recorder.start()
        lastSentChunk = 0

        // Start live session for real-time preview
        liveAccumulatedText = ""
        startNewLiveSession()

        // Timer to send audio chunks to live session
        DispatchQueue.main.async { [weak self] in
            self?.liveSendTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
                guard let self = self, self.recorder.isRecording else { return }
                let frames = self.recorder.getFramesSoFar()
                let newFrames = Array(frames.dropFirst(self.lastSentChunk))
                self.lastSentChunk = frames.count
                if !newFrames.isEmpty {
                    for frame in newFrames {
                        self.liveSession?.sendAudio(frame)
                    }
                }
            }

            // Reconnect live session every 15s to prevent degradation
            self?.liveReconnectTimer = Timer.scheduledTimer(withTimeInterval: 15.0, repeats: true) { [weak self] _ in
                guard let self = self, self.recorder.isRecording else { return }
                // Save text from current session before reconnecting
                if let currentSession = self.liveSession {
                    self.liveAccumulatedText = self.liveAccumulatedText + currentSession.currentText
                    log("[live] 🔄 Reconnecting (15s). Accumulated: \(self.liveAccumulatedText.count) chars")
                    currentSession.stop()
                }
                self.startNewLiveSession()
            }
        }
        log("🎙 Recording started")
    }

    func startNewLiveSession() {
        liveSession = LiveSession()
        liveSession?.start { [weak self] sessionText in
            guard let self = self else { return }
            // Combine accumulated text from previous sessions + current session text
            let fullText = self.liveAccumulatedText + sessionText
            let display = fullText.count > 60 ? String(fullText.suffix(60)) : fullText
            self.hud.updateText(display)
        }
    }

    func stopRecording() {
        guard recorder.isRecording else { return }

        let duration = recordingStartTime.map { Date().timeIntervalSince($0) } ?? 0
        log("🎙 Stopping recording... (\(String(format: "%.2f", duration))s)")

        let frames = recorder.stop()

        DispatchQueue.main.async { [weak self] in
            self?.liveSendTimer?.invalidate()
            self?.liveSendTimer = nil
            self?.liveReconnectTimer?.invalidate()
            self?.liveReconnectTimer = nil
        }
        liveSession?.stop()
        liveSession = nil
        liveAccumulatedText = ""

        // Ignore accidental taps (< minRecordingDuration, default 2.0s)
        if duration < minRecordingDuration {
            log("  ⏭ Too short (\(String(format: "%.2f", duration))s < \(minRecordingDuration)s) — discarded")
            hud.hide()
            return
        }

        playSound(sfxStop())

        isTranscribing = true
        hud.isProcessing = true  // switch wave direction
        hud.updateText(L("finalizing"))

        guard !frames.isEmpty else {
            log("  No audio frames captured")
            isTranscribing = false
            hud.hide()
            return
        }

        guard usage.remaining > 0 else {
            isTranscribing = false
            playSound(sfxError())
            hud.showResult("\(L("daily_limit")) (\(DAILY_FREE_LIMIT))", isError: true)
            return
        }

        let (wavData, audioDuration) = encodeToWAV(frames: frames)
        log("  Audio: \(String(format: "%.1f", audioDuration))s, \(wavData.count) bytes, \(frames.count) chunks")

        transcribeREST(wavData: wavData) { [weak self] result in
            guard let self = self else { return }
            self.isTranscribing = false
            switch result {
            case .success(let text):
                let count = usage.increment()
                log("✅ [\(count)/\(DAILY_FREE_LIMIT)] \(text)")
                playSound(sfxDone())
                self.hud.showResult(text)
                self.lastTranscription = text
                if self.isOnboarding {
                    let escapedText = text
                        .replacingOccurrences(of: "\\", with: "\\\\")
                        .replacingOccurrences(of: "'", with: "\\'")
                        .replacingOccurrences(of: "\n", with: "\\n")
                        .replacingOccurrences(of: "\r", with: "")
                    DispatchQueue.main.async { [weak self] in
                        self?.onboardingWindow.webView?.evaluateJavaScript("onTranscriptionResult('\(escapedText)')", completionHandler: nil)
                    }
                }
                // Always copy and paste, even during onboarding
                self.copyAndPaste(text)
            case .failure(let error):
                log("❌ Transcription error: \(error.localizedDescription)")
                playSound(sfxError())
                self.hud.showResult(error.localizedDescription, isError: true)
            }
        }
    }

    func copyAndPaste(_ text: String) {
        // Copy to clipboard
        DispatchQueue.main.async {
            NSPasteboard.general.clearContents()
            let success = NSPasteboard.general.setString(text, forType: .string)
            log("📋 Clipboard: \(success ? "OK" : "FAILED")")

            // Simulate Cmd+V after a short delay
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                let src = CGEventSource(stateID: .hidSystemState)
                // V key = 0x09
                if let keyDown = CGEvent(keyboardEventSource: src, virtualKey: 0x09, keyDown: true),
                   let keyUp = CGEvent(keyboardEventSource: src, virtualKey: 0x09, keyDown: false) {
                    keyDown.flags = .maskCommand
                    keyDown.post(tap: .cghidEventTap)
                    keyUp.flags = .maskCommand
                    keyUp.post(tap: .cghidEventTap)
                    log("📋 Cmd+V posted")
                } else {
                    log("❌ Failed to create CGEvent for Cmd+V")
                    // Fallback: use osascript
                    DispatchQueue.global().async {
                        let proc = Process()
                        proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
                        proc.arguments = ["-e", "tell application \"System Events\" to keystroke \"v\" using command down"]
                        try? proc.run()
                        proc.waitUntilExit()
                        log("📋 osascript Cmd+V fallback: exit \(proc.terminationStatus)")
                    }
                }
            }
        }
    }

    // MARK: - Menu Actions

    @objc func toggleRecording() {
        if recorder.isRecording {
            DispatchQueue.global(qos: .userInitiated).async { [weak self] in
                self?.stopRecording()
            }
        } else {
            DispatchQueue.global(qos: .userInitiated).async { [weak self] in
                self?.startRecording()
            }
        }
    }

    @objc func copyLastResult() {
        guard !lastTranscription.isEmpty else { return }
        NSPasteboard.general.clearContents()
        NSPasteboard.general.setString(lastTranscription, forType: .string)
        log("📋 Copied last: \(lastTranscription.prefix(50))")
    }

    @objc func editDictionary() {
        let words = loadDictionary()
        let current = words.joined(separator: ", ")
        let prompt = currentLang == "zh"
            ? "编辑自定义词汇表（逗号分隔）\\n\\n这些词会被精确识别，不会被替换成发音相似的词。\\n例如：Claude 不会被识别成 cloud"
            : "Edit custom dictionary (comma-separated)\\n\\nThese words will be transcribed exactly as written.\\nExample: Claude won't be misheard as cloud"
        let script = """
        tell application "System Events"
          display dialog "\(prompt)" default answer "\(current)" with title "虾BB Dictionary" buttons {"Cancel", "Save"} default button "Save"
          set theWords to text returned of result
          return theWords
        end tell
        """
        let proc = Process()
        proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
        proc.arguments = ["-e", script]
        let pipe = Pipe()
        proc.standardOutput = pipe
        proc.standardError = Pipe()
        try? proc.run()
        proc.waitUntilExit()
        let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
        if proc.terminationStatus == 0 && !output.isEmpty {
            let newWords = output.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
            saveDictionary(newWords)
            log("📖 Dictionary updated: \(newWords.count) words")
            // Show confirmation
            let p2 = Process()
            p2.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
            p2.arguments = ["-e", "tell application \"System Events\" to display notification \"词汇表已保存 ✅ (\(newWords.count) 个词)\" with title \"虾BB\""]
            try? p2.run()
        }
    }

    @objc func configureAPI() {
        // Show current key masked (first 8 + *** + last 4)
        let currentDisplay: String
        if apiKey.isEmpty {
            currentDisplay = ""
        } else if apiKey.count > 16 {
            currentDisplay = String(apiKey.prefix(8)) + "****" + String(apiKey.suffix(4))
        } else {
            currentDisplay = String(apiKey.prefix(4)) + "****"
        }
        let prompt = currentLang == "zh"
            ? "输入 Gemini API Key\\n(免费获取: aistudio.google.com/apikey)\\n\\n留空点 Save 不会清除已有 Key"
            : "Enter Gemini API Key\\n(Free: aistudio.google.com/apikey)\\n\\nLeave empty + Save won't clear existing key"
        let script = """
        tell application "System Events"
          display dialog "\(prompt)" default answer "\(currentDisplay)" with title "XiaBB API Config" buttons {"Cancel", "Save"} default button "Save"
          set theKey to text returned of result
          return theKey
        end tell
        """
        let proc = Process()
        proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
        proc.arguments = ["-e", script]
        let pipe = Pipe()
        proc.standardOutput = pipe
        proc.standardError = Pipe()
        try? proc.run()
        proc.waitUntilExit()
        let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
        // Only save if user entered a new key (not the masked display)
        if proc.terminationStatus == 0 && !output.isEmpty && output != currentDisplay {
            let keyFile = dataDir.appendingPathComponent(".api-key")
            try? output.write(to: keyFile, atomically: true, encoding: .utf8)
            [REDACTED_SECRET]
            log("🔑 API key saved")
            // Show confirmation
            let confirmScript = """
            tell application "System Events"
              display notification "API Key 已保存 ✅" with title "XiaBB"
            end tell
            """
            let p2 = Process()
            p2.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
            p2.arguments = ["-e", confirmScript]
            try? p2.run()
        }
    }

    @objc func toggleLaunchAtLogin() {
        let currently = isLaunchAtLogin()
        setLaunchAtLogin(!currently)
        menuItems["launch"]?.state = currently ? .off : .on
    }

    @objc func setLangEN() {
        currentLang = "en"
        saveConfig(["lang": "en"])
        refreshMenuTitles()
    }

    @objc func setLangZH() {
        currentLang = "zh"
        saveConfig(["lang": "zh"])
        refreshMenuTitles()
    }

    @objc func switchTheme(_ sender: NSMenuItem) {
        guard let themeId = sender.representedObject as? String,
              let theme = themes[themeId] else { return }
        currentTheme = theme
        saveConfig(["theme": themeId])
        // Update checkmarks
        if let themeMenu = sender.menu {
            for item in themeMenu.items { item.state = .off }
        }
        sender.state = .on
        // Rebuild HUD with new theme
        hud = HUDOverlay()
        log("🎨 Theme: \(theme.name)")
    }

    @objc func showPermissions() {
        onboardingWindow.show()
    }

    @objc func quit() {
        NSApp.terminate(nil)
    }

    func refreshMenuTitles() {
        menuItems["hotkey"]?.title = L("hotkey")
        menuItems["toggle"]?.title = recorder.isRecording ? L("stop") : L("start")
        menuItems["api"]?.title = L("configure_api")
        menuItems["launch"]?.title = L("launch_login")
        menuItems["lang"]?.title = L("language")
        menuItems["quit"]?.title = L("quit")
        menuItems["en"]?.state = currentLang == "en" ? .on : .off
        menuItems["zh"]?.state = currentLang == "zh" ? .on : .off
    }

    // MARK: - Launch at Login

    let plistPath = URL(fileURLWithPath: NSHomeDirectory())
        .appendingPathComponent("Library/LaunchAgents/com.xiabb.plist")

    func isLaunchAtLogin() -> Bool {
        FileManager.default.fileExists(atPath: plistPath.path)
    }

    func setLaunchAtLogin(_ enabled: Bool) {
        if enabled {
            let appPath = Bundle.main.bundlePath
            let plist = """
            <?xml version="1.0" encoding="UTF-8"?>
            <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
            <plist version="1.0"><dict>
              <key>Label</key><string>com.xiabb</string>
              <key>ProgramArguments</key><array>
                <string>/usr/bin/open</string>
                <string>-a</string>
                <string>\(appPath)</string>
              </array>
              <key>RunAtLoad</key><true/>
              <key>KeepAlive</key><false/>
            </dict></plist>
            """
            try? plist.write(to: plistPath, atomically: true, encoding: .utf8)
        } else {
            try? FileManager.default.removeItem(at: plistPath)
        }
    }

    // MARK: - Tick

    func tick() {
        tickCount += 1
        hud.tickPulse()

        if let btn = statusItem.button {
            if recorder.isRecording {
                btn.image = iconRec
                btn.alphaValue = 1.0
            } else if isTranscribing {
                btn.image = iconRec
                let phase = Double(tickCount) * 0.15
                btn.alphaValue = CGFloat(0.4 + 0.6 * abs(sin(phase)))
            } else {
                btn.image = iconIdle
                btn.alphaValue = 1.0
            }
        }

        if recorder.isRecording {
            statusMenuItem.title = "\(L("recording")) — \(usage.statusLine())"
            toggleItem.title = L("stop")
        } else if isTranscribing {
            statusMenuItem.title = "\(L("transcribing")) — \(usage.statusLine())"
        } else {
            statusMenuItem.title = "\(L("idle")) — \(usage.statusLine())"
            toggleItem.title = L("start")
        }
    }
}

// MARK: - Main

if apiKey.isEmpty {
    log("⚠️ No API key — set GEMINI_API_KEY env var or create ~/Tools/xiabb/.api-key")
    log("   Get a free key at https://aistudio.google.com/apikey")
}

let app = NSApplication.shared
let controller = XiaBBApp()
controller.setup()
app.run()


path: design-versions/v2-coral-sketchy/stitch-landing-page/README.md
content: <div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>

# Run and deploy your AI Studio app

This contains everything you need to run your app locally.

View your app in AI Studio: https://ai.studio/apps/5f361534-c0ae-4543-85e2-6b45109beea9

## Run Locally

**Prerequisites:**  Node.js


1. Install dependencies:
   `npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
   `npm run dev`


path: design-versions/v2-coral-sketchy/stitch-landing-page/index.html
content: <!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My Google AI Studio App</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>



path: design-versions/v2-coral-sketchy/stitch-landing-page/metadata.json
content: {
  "name": "XiaBB",
  "description": "A free macOS voice-to-text tool built for Vibe Coding, powered by Google's Gemini API.",
  "requestFramePermissions": []
}


path: design-versions/v2-coral-sketchy/stitch-landing-page/package.json
content: {
  "name": "react-example",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite --port=3000 --host=0.0.0.0",
    "build": "vite build",
    "preview": "vite preview",
    "clean": "rm -rf dist",
    "lint": "tsc --noEmit"
  },
  "dependencies": {
    "@google/genai": "^1.29.0",
    "@tailwindcss/vite": "^4.1.14",
    "@vitejs/plugin-react": "^5.0.4",
    "lucide-react": "^0.546.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "vite": "^6.2.0",
    "express": "^4.21.2",
    "dotenv": "^17.2.3",
    "motion": "^12.23.24"
  },
  "devDependencies": {
    "@types/node": "^22.14.0",
    "autoprefixer": "^10.4.21",
    "tailwindcss": "^4.1.14",
    "tsx": "^4.21.0",
    "typescript": "~5.8.2",
    "vite": "^6.2.0",
    "@types/express": "^4.17.21"
  }
}


path: design-versions/v2-coral-sketchy/stitch-landing-page/src/App.tsx
content: /**
 * @license
 * SPDX-License-Identifier: Apache-2.0
 */

import { useState, useEffect } from "react";
import { motion, useScroll, useTransform } from "motion/react";
import { 
  Download, 
  Github, 
  CheckCircle2, 
  XCircle, 
  Terminal as TerminalIcon, 
  Copy, 
  Check, 
  Globe, 
  Mic, 
  Zap, 
  Code, 
  Eye, 
  Box, 
  Cpu, 
  Languages, 
  DollarSign 
} from "lucide-react";

const Navbar = () => (
  <header className="fixed top-0 left-0 right-0 z-50 px-6 py-4">
    <nav className="max-w-6xl mx-auto flex items-center justify-between bg-charcoal/95 backdrop-blur-sm px-6 py-3 sketchy-box">
      <div className="flex items-center gap-2">
        <span className="text-coral text-2xl font-bold font-serif">XiaBB</span>
      </div>
      <div className="hidden md:flex items-center gap-8 text-sm font-medium">
        <a className="hover:text-coral transition-colors" href="#how-it-works">How It Works</a>
        <a className="hover:text-coral transition-colors" href="#features">Features</a>
        <a className="hover:text-coral transition-colors" href="#compare">Compare</a>
        <a className="hover:text-coral transition-colors" href="#terminal">Terminal</a>
      </div>
      <div className="flex items-center gap-4">
        <a className="text-sm hover:text-coral transition-colors" href="https://github.com" target="_blank" rel="noreferrer">GitHub</a>
        <button className="bg-coral text-charcoal px-4 py-2 text-sm font-bold sketchy-button hover:bg-transparent hover:text-coral">
          Download
        </button>
      </div>
    </nav>
  </header>
);

const Hero = () => {
  const { scrollY } = useScroll();
  const y = useTransform(scrollY, [0, 500], [0, 50]);
  const rotate = useTransform(scrollY, [0, 500], [2, 12]);

  return (
    <section className="max-w-6xl mx-auto px-6 pt-32 pb-24 flex flex-col md:flex-row items-center gap-12" id="hero">
      <motion.div 
        initial={{ opacity: 0, x: -50 }}
        animate={{ opacity: 1, x: 0 }}
        transition={{ duration: 0.6 }}
        className="flex-1 space-y-8"
      >
        <h1 className="text-6xl md:text-8xl font-serif font-bold leading-tight">
          Stop Typing.<br />
          Start <span className="text-coral">瞎BB</span>.
        </h1>
        <p className="text-xl text-cream/80 max-w-lg leading-relaxed">
          The free macOS voice-to-text tool built for Vibe Coding. 
          Powered by Google's best-in-class Gemini API. Keep your flow, ditch the keys.
        </p>
        <div className="flex flex-wrap gap-4">
          <button className="bg-coral text-charcoal px-8 py-4 text-lg font-bold sketchy-button flex items-center gap-2">
            <Download className="w-5 h-5" />
            Download for macOS
          </button>
          <button className="px-8 py-4 text-lg font-bold sketchy-button flex items-center gap-2">
            <Github className="w-5 h-5" />
            View on GitHub
          </button>
        </div>
        <div className="flex items-center gap-2 text-sm text-cream/60">
          <CheckCircle2 className="w-4 h-4 text-green-accent" />
          Apple Silicon (M1/M2/M3) & Intel Support
        </div>
      </motion.div>
      
      <motion.div 
        style={{ y, rotate }}
        initial={{ opacity: 0, scale: 0.8 }}
        animate={{ opacity: 1, scale: 1 }}
        transition={{ duration: 0.8, delay: 0.2 }}
        className="flex-1 relative"
      >
        <div className="relative z-10 p-4 sketchy-box bg-surface">
          <img 
            alt="XiaBB Fusion Mascot" 
            className="w-full h-auto rounded-lg grayscale hover:grayscale-0 transition-all duration-500" 
            src="https://lh3.googleusercontent.com/aida-public/AB6AXuBp0Rjg5-jfyS3hoTZx-aM9ydZ0yQytUGTNI7Off4jsWszW-YuI7l5DCGxGLev56LYEZ5o_Yi53IHfDzqISqU8kne3q0-8sY6bSZ1h2CoAgkgn_Y2htvPjs-qoaNsT1j8VP4pQ5m1fSEfQSLgHKEmHyfg1vUPPIq6wBwdeEvwjJw0kU9RrudKgRZpkB5eZe7JI33IdS6O2Ck5fpncn5x4ZV43BytZKkIqP33mDiM7R6mGyOvK3RTpwSWMutc0P4nu9DQUqP8MkNj0w"
            referrerPolicy="no-referrer"
          />
          <div className="absolute -top-4 -right-4 bg-yellow-accent text-charcoal px-3 py-1 text-xs font-bold rotate-12 sketchy-box">
            *bzzt*
          </div>
        </div>
        <div className="absolute -top-10 -left-10 w-32 h-32 bg-coral/10 rounded-full blur-3xl"></div>
        <div className="absolute -bottom-10 -right-10 w-48 h-48 bg-yellow-accent/5 rounded-full blur-3xl"></div>
      </motion.div>
    </section>
  );
};

const HowItWorks = () => (
  <section className="py-24 bg-surface/30" id="how-it-works">
    <div className="max-w-5xl mx-auto px-6 text-center mb-16">
      <h2 className="text-4xl font-serif font-bold mb-4">Zero config. Just talk.</h2>
      <p className="text-cream/60">Three steps from a thought in your head to markdown on your screen.</p>
    </div>
    <div className="max-w-6xl mx-auto px-6 relative">
      <div className="absolute left-1/2 top-0 bottom-0 timeline-line -translate-x-1/2 hidden md:block"></div>
      
      {/* Step 1 */}
      <div className="flex flex-col md:flex-row items-center gap-12 mb-24 relative">
        <div className="flex-1 text-right hidden md:block">
          <h3 className="text-2xl font-bold mb-2">Press and hold Globe key</h3>
          <p className="text-cream/60">Invoke BB instantly from anywhere. No need to click around or find the window.</p>
        </div>
        <div className="z-10 bg-coral text-charcoal w-10 h-10 rounded-full flex items-center justify-center font-bold border-2 border-cream">1</div>
        <div className="flex-1">
          <div className="sketchy-box p-2 bg-surface -rotate-1">
            <img 
              alt="Press Globe Key" 
              className="w-full h-48 object-cover rounded shadow-lg" 
              src="https://lh3.googleusercontent.com/aida-public/AB6AXuCzjaCKn3neUF_bFSv4dtNDSOb_LBu1bEQ-v7sB9tzJiiig7lptCcslVN_FIkzQcKkCvmRbOfHbFQC3X-7zT6Mj-vxBAF-EFlssnWTDc2aZCGXbrp71U2nmqC20P6w9giRB1n1-HDLJsLi-1EMbMyqt8wxCgN95zn_AidZwUdF9mFubday6spjKilmrufHz5vWNeJuno9yQiDQnGvxZeUVBELKv9WD1JUTaRdO4j0nVOVYsuDdoLEJmVYwyV_HdQlXj8RQVP4SKrmU"
              referrerPolicy="no-referrer"
            />
          </div>
          <div className="md:hidden mt-4 text-center">
            <h3 className="text-xl font-bold mb-2">Press and hold Globe key</h3>
            <p className="text-cream/60 text-sm">Invoke BB instantly from anywhere.</p>
          </div>
        </div>
      </div>

      {/* Step 2 */}
      <div className="flex flex-col md:flex-row-reverse items-center gap-12 mb-24 relative">
        <div className="flex-1 text-left hidden md:block">
          <h3 className="text-2xl font-bold mb-2">Speak your mind</h3>
          <p className="text-cream/60">Rambling is encouraged. BB processes your voice locally first, understanding context and code naturally.</p>
        </div>
        <div className="z-10 bg-yellow-accent text-charcoal w-10 h-10 rounded-full flex items-center justify-center font-bold border-2 border-cream">2</div>
        <div className="flex-1">
          <div className="sketchy-box p-2 bg-surface rotate-1">
            <img 
              alt="Lobster Mascot Talking" 
              className="w-full h-48 object-cover rounded shadow-lg" 
              src="https://lh3.googleusercontent.com/aida-public/AB6AXuDKNtzSwfHUamH71heg4okO3eNVbZbAkWbQYgu3FfcXTNLXNtNC70BSYEFGkCgWKs1SkH2V_i2fgN51wy8d5Z0qizo2tqXBXJcasuVnUGtdIOn02bMT96jkWubnAIAxIA3Vt2O3Ei9APQWm97PB_6KeYuhT8hlx9bvRyZ_F1T8g7IQm7nyJuZBLq6Vx_A4hROVpHXok8KfgseEmHlZrUVoa6gJwha8pqixEaJZfZUmPEZGnUiSa_Z6HREqI7j_q6IbAx317Q9XQ-N8"
              referrerPolicy="no-referrer"
            />
          </div>
          <div className="md:hidden mt-4 text-center">
            <h3 className="text-xl font-bold mb-2">Speak your mind</h3>
            <p className="text-cream/60 text-sm">BB processes your voice with context.</p>
          </div>
        </div>
      </div>

      {/* Step 3 */}
      <div className="flex flex-col md:flex-row items-center gap-12 relative">
        <div className="flex-1 text-right hidden md:block">
          <h3 className="text-2xl font-bold mb-2">Text appears instantly</h3>
          <p className="text-cream/60">Done talking? Your nonsense is already formatted as perfect markdown and copied to your clipboard.</p>
        </div>
        <div className="z-10 bg-green-accent text-charcoal w-10 h-10 rounded-full flex items-center justify-center font-bold border-2 border-cream">3</div>
        <div className="flex-1">
          <div className="sketchy-box p-4 bg-charcoal border-green-accent/50 -rotate-1">
            <div className="flex gap-1.5 mb-3">
              <div className="w-2.5 h-2.5 rounded-full bg-coral/50"></div>
              <div className="w-2.5 h-2.5 rounded-full bg-yellow-accent/50"></div>
              <div className="w-2.5 h-2.5 rounded-full bg-green-accent/50"></div>
            </div>
            <code className="text-sm font-mono block">
              <span className="text-coral"># Project Ideas</span><br />
              - Build a lo-fi dictation app<br />
              - Name the mascot <span className="text-yellow-accent">BB</span><br />
              - 搞一个中英混搭的 <span className="text-green-accent">Vibe Coding</span> 工具
            </code>
          </div>
          <div className="md:hidden mt-4 text-center">
            <h3 className="text-xl font-bold mb-2">Text appears instantly</h3>
            <p className="text-cream/60 text-sm">Formatted markdown ready to paste.</p>
          </div>
        </div>
      </div>
    </div>
  </section>
);

const Features = () => {
  const features = [
    {
      icon: <DollarSign className="w-8 h-8" />,
      title: "永久免费 (Gemini API)",
      desc: "Powered by your own Gemini API key. Free tier is generous enough for 24/7 rambling."
    },
    {
      icon: <Languages className="w-8 h-8" />,
      title: "中英混搭",
      desc: "Perfectly understands \"Vibe coding is so cool 真的太顶了\" without missing a beat."
    },
    {
      icon: <Eye className="w-8 h-8" />,
      title: "实时预览",
      desc: "Watch your nonsense turn into structure in real-time as you speak."
    },
    {
      icon: <Box className="w-8 h-8" />,
      title: "280KB 极简",
      desc: "Lightweight binary. No Electron bloat. Just a clean Swift tool sitting in your menu bar."
    },
    {
      icon: <Code className="w-8 h-8" />,
      title: "开源 MIT",
      desc: "Hackable and transparent. Check the source, build your own version, keep it forever."
    },
    {
      icon: <Zap className="w-8 h-8" />,
      title: "LLM 引擎",
      desc: "Not just STT. It's an intelligent layer that formats code, cleans up \"umms\", and adds markdown."
    }
  ];

  return (
    <section className="py-24 max-w-6xl mx-auto px-6" id="features">
      <div className="text-center mb-16">
        <h2 className="text-4xl font-serif font-bold mb-4 text-yellow-accent">Built for midnight coding sessions.</h2>
      </div>
      <div className="grid grid-cols-1 md:grid-cols-3 gap-8">
        {features.map((f, i) => (
          <motion.div 
            key={i}
            whileHover={{ y: -5, x: -2 }}
            className="bg-surface p-8 sketchy-box hover:shadow-[6px_6px_0_0_rgba(234,105,98,0.8)] transition-all"
          >
            <div className="text-coral mb-4">{f.icon}</div>
            <h3 className="text-xl font-bold mb-3">{f.title}</h3>
            <p className="text-cream/70 text-sm leading-relaxed">{f.desc}</p>
          </motion.div>
        ))}
      </div>
    </section>
  );
};

const Comparison = () => (
  <section className="py-24 max-w-6xl mx-auto px-6" id="compare">
    <div className="text-center mb-16">
      <h2 className="text-4xl font-serif font-bold mb-4">Why choose XiaBB?</h2>
      <p className="text-cream/60">Ditch the subscriptions and cloud latency. Keep your thoughts local.</p>
    </div>
    <div className="grid md:grid-cols-2 gap-8 items-stretch">
      <div className="bg-surface/50 p-10 sketchy-box border-charcoal opacity-80">
        <div className="flex items-center gap-3 mb-8 text-cream/40">
          <XCircle className="w-6 h-6" />
          <span className="text-2xl font-bold">Them</span>
        </div>
        <ul className="space-y-6">
          <li className="flex items-center gap-3 text-cream/50">
            <span className="text-coral">✕</span> Requires proprietary API keys
          </li>
          <li className="flex items-center gap-3 text-cream/50">
            <span className="text-coral">✕</span> Monthly subscriptions
          </li>
          <li className="flex items-center gap-3 text-cream/50">
            <span className="text-coral">✕</span> Cloud latency issues
          </li>
          <li className="flex items-center gap-3 text-cream/50">
            <span className="text-coral">✕</span> Privacy concerns & data logging
          </li>
        </ul>
      </div>
      
      <div className="bg-surface p-10 sketchy-box border-coral relative">
        <div className="absolute -top-4 -right-4 bg-green-accent text-charcoal px-3 py-1 text-sm font-bold rotate-6 sketchy-box">
          Free Forever
        </div>
        <div className="flex items-center gap-3 mb-8 text-coral">
          <CheckCircle2 className="w-6 h-6" />
          <span className="text-2xl font-bold">XiaBB</span>
        </div>
        <ul className="space-y-6">
          <li className="flex items-center gap-3 font-medium">
            <span className="text-green-accent">✓</span> Zero config out of the box
          </li>
          <li className="flex items-center gap-3 font-medium">
            <span className="text-green-accent">✓</span> 100% Free & Open Source
          </li>
          <li className="flex items-center gap-3 font-medium">
            <span className="text-green-accent">✓</span> Instant local processing
          </li>
          <li className="flex items-center gap-3 font-medium">
            <span className="text-green-accent">✓</span> Total offline privacy
          </li>
        </ul>
      </div>
    </div>
  </section>
);

const Terminal = () => {
  const [copied, setCopied] = useState(false);
  const command = "brew install --cask xiabb";

  const copyCommand = () => {
    navigator.clipboard.writeText(command);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  };

  return (
    <section className="py-24 max-w-4xl mx-auto px-6" id="terminal">
      <div className="sketchy-box bg-black/40 overflow-hidden">
        <div className="bg-surface px-4 py-2 flex items-center gap-2 border-b border-cream/20">
          <div className="w-3 h-3 rounded-full bg-coral"></div>
          <div className="w-3 h-3 rounded-full bg-yellow-accent"></div>
          <div className="w-3 h-3 rounded-full bg-green-accent"></div>
          <span className="text-xs text-cream/40 font-mono ml-2">terminal — zsh</span>
        </div>
        <div className="p-8 font-mono text-lg flex flex-col md:flex-row items-start md:items-center gap-4">
          <div className="flex items-center gap-4 flex-1">
            <span className="text-green-accent">$</span>
            <code className="text-cream">{command}</code>
          </div>
          <button 
            onClick={copyCommand}
            className="text-cream/40 hover:text-coral transition-colors flex items-center gap-2"
            title="Copy command"
          >
            {copied ? <Check className="w-6 h-6 text-green-accent" /> : <Copy className="w-6 h-6" />}
            <span className="text-xs uppercase font-bold tracking-widest">{copied ? "Copied!" : "Copy"}</span>
          </button>
        </div>
      </div>
    </section>
  );
};

const Footer = () => (
  <footer className="py-12 border-t border-cream/10 mt-12">
    <div className="max-w-6xl mx-auto px-6 flex flex-col md:flex-row justify-between items-center gap-8">
      <div className="text-center md:text-left">
        <h4 className="text-xl font-serif font-bold text-coral mb-2">XiaBB</h4>
        <p className="text-cream/40 text-sm">Built with ❤️ for the Vibe Coding community.</p>
      </div>
      <div className="flex gap-8 text-sm text-cream/60">
        <a className="hover:text-coral transition-colors" href="#">Documentation</a>
        <a className="hover:text-coral transition-colors" href="#">Privacy</a>
        <a className="hover:text-coral transition-colors" href="#">Twitter</a>
        <a className="hover:text-coral transition-colors" href="#">MIT License</a>
      </div>
      <div className="text-sm text-cream/40 italic">
        © 2024 XiaBB Studio. No lobsters were harmed.
      </div>
    </div>
  </footer>
);

export default function App() {
  return (
    <div className="min-h-screen">
      <Navbar />
      <main>
        <Hero />
        <HowItWorks />
        <Features />
        <Comparison />
        <Terminal />
      </main>
      <Footer />
    </div>
  );
}


path: design-versions/v2-coral-sketchy/stitch-landing-page/src/index.css
content: @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=Zilla+Slab:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
@import "tailwindcss";

@theme {
  --color-charcoal: #282828;
  --color-coral: #EA6962;
  --color-cream: #EBDBB2;
  --color-green-accent: #A9B665;
  --color-yellow-accent: #D8A657;
  --color-surface: #32302F;

  --font-sans: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
  --font-serif: "Zilla Slab", serif;
  --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
}

@layer base {
  body {
    @apply bg-charcoal text-cream font-sans antialiased;
    scroll-behavior: smooth;
  }

  /* Subtle Grainy Texture Overlay */
  body::before {
    content: "";
    @apply fixed inset-0 opacity-4 pointer-events-none z-[9999];
    background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
  }
}

@layer components {
  .sketchy-box {
    @apply border-2 border-cream rounded-[255px_15px_225px_15px/15px_225px_15px_255px];
  }

  .sketchy-button {
    @apply border-2 border-cream rounded-[12px_3px_14px_4px/4px_14px_3px_12px] transition-all duration-200;
  }

  .sketchy-button:hover {
    @apply border-coral text-coral;
  }

  .timeline-line {
    background-image: linear-gradient(to bottom, #EBDBB2 50%, transparent 50%);
    background-size: 2px 20px;
    width: 2px;
  }
}


path: design-versions/v2-coral-sketchy/stitch-landing-page/src/main.tsx
content: import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import './index.css';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);


path: design-versions/v2-coral-sketchy/stitch-landing-page/tsconfig.json
content: {
  "compilerOptions": {
    "target": "ES2022",
    "experimentalDecorators": true,
    "useDefineForClassFields": false,
    "module": "ESNext",
    "lib": [
      "ES2022",
      "DOM",
      "DOM.Iterable"
    ],
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "isolatedModules": true,
    "moduleDetection": "force",
    "allowJs": true,
    "jsx": "react-jsx",
    "paths": {
      "@/*": [
        "./*"
      ]
    },
    "allowImportingTsExtensions": true,
    "noEmit": true
  }
}


path: uninstall.sh
content: #!/bin/bash
echo "Uninstalling XiaBB..."

# Stop if running
pkill -f "XiaBB.app/Contents/MacOS/XiaBB" 2>/dev/null

# Remove auto-start
launchctl unload ~/Library/LaunchAgents/com.xiabb.plist 2>/dev/null
rm -f ~/Library/LaunchAgents/com.xiabb.plist

# Remove app
rm -rf "/Applications/XiaBB.app"

# Remove log
rm -f ~/Library/Logs/XiaBB.log

echo "Uninstalled."
echo "   Config and API key at ~/Tools/xiabb/ left intact."
echo "   To fully remove: rm -rf ~/Tools/xiabb/"


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)