Skill: jerryang-cool/trtc-ai-customer-service (version 1.2.5)

--- name: trtc-ai-customer-service version: 1.2.5 description: | Build an AI e-commerce customer service Web app with TRTC ConversationAI — real-time voice/text dual-mode, trilingual (Chinese/English/Cantonese), digital avatar optional. Covers order inquiry, returns, shipping tracking, and promotions. 基于腾讯云 TRTC Conversational AI 快速构建 AI 电商客服 Web 应用 — 实时语音/文字双模、中英粤三语、数字人可选。覆盖订单查询、退换货、物流追踪、商品咨询等场景。 homepage: https://github.com/jerryang-cool/trtc-ai-customer-service-skill metadata: openclaw: emoji: "🛍️" requires: bins: - python3 --- # TRTC AI 电商客服 Skill 本 Skill 指导你基于腾讯云 TRTC Conversational AI 能力，快速构建 AI 电商客服 Web 应用。 场景预置了订单查询、退换货处理、商品咨询、物流追踪、优惠活动等电商业务模块。 ## 触发条件 当用户**表达构建/搭建/集成意图**时使用此 Skill： - "做一个 AI 客服" "搭建电商客服" "帮我做个智能客服系统" "语音客服 demo" - "build an AI customer service" "e-commerce customer service app" - "TRTC ConversationAI" "TRTC + AI 客服" "TRTC e-commerce support" - "StartAIConversation" "StopAIConversation" "ControlAIConversation"（TRTC API 名称） - "数字人客服" "avatar customer service" - "实时语音 AI 对话" "ASR + LLM + TTS 客服" **不应触发的场景**： - 用户仅在讨论客服概念，没有构建/开发意图 - 用户询问通用 chat bot 方案，不需要语音能力或电商场景 - 关键词如 "voice bot" "chat bot" 单独出现且无构建上下文 ## 架构总览 ``` 浏览器 (TRTC Web SDK v5) ↕ 音频 (WebRTC) + 自定义消息 (字幕/状态/文字输入) TRTC Room ↕ 内置 ASR → LLM → TTS → 推回房间 TRTC AI Bot (云端) ↕ OpenAPI (TC3-HMAC-SHA256) Flask 后端 (app.py) —— 仅 UserSig 签发 + OpenAPI 中转 ``` | 平面 | 通道 | 内容 | |------|------|------| | **媒体面** | WebRTC 音频流 | 用户麦克风 ↔ TRTC 房间 ↔ AI Bot | | **控制面** | HTTP `/action` | 前端 → Flask → TRTC OpenAPI | | **数据面** | TRTC 自定义消息 | 字幕(10000) / AI 状态(10001) / 文字输入(20000) / 打断(20001) | 后端**完全不调用 LLM**——LLM 由 TRTC 云端 AI Bot 内部调用，后端只负责签发 UserSig 和中转 OpenAPI 请求。 --- ## 工作流程 根据用户需求选择合适的路径。 ### 路径 A：从零创建新项目（推荐） #### Step 1: 生成项目 运行脚手架脚本： ```bash python {baseDir}/scripts/scaffold.py <项目目录> [--name <商城名称>] [--name-en <English name>] ``` - `{baseDir}`：本 Skill 所在目录的绝对路径（由 Agent 自动替换为实际路径） - `--name`：商城名称（默认"云尚商城"），用于中文/粤语的 SystemPrompt、欢迎语、告别语、前端 UI - `--name-en`：英文商城名称（默认自动推导：中文名时为"CloudShop Mall"，英文名时与 `--name` 相同），用于英文 SystemPrompt、英文欢迎语/告别语 - 默认支持中文/英文/粤语三语，无需手动指定语言 - 脚本自动生成全部文件：后端 + 前端 + 头像 + 鉴权库 + 启动脚本，无需手动复制任何文件 **检查点**：确认用户看到 `✅ 电商客服项目已生成到: xxx` 和完整文件列表，再继续。 #### Step 2: 配置密钥 引导用户运行启动脚本（**根据操作系统自动选择**：macOS/Linux 用 `./start.sh`，Windows 用 `start.bat`），首次运行会进入交互式引导： - [0/4] 选择部署区域（默认 `intl` 国际站，可选 `cn` 中国站）— 后续步骤会根据所选区域展示对应的控制台链接 - [1/4] 腾讯云 API 密钥 → 脚本会展示对应区域的 [CAM 控制台](https://console.intl.cloud.tencent.com/cam/capi) 链接 - [2/4] TRTC 应用凭据 → 脚本会展示对应区域的 [TRTC 控制台](https://console.trtc.io/app) 链接 - [3/4] LLM 配置 → **建议优先使用 TokenHub**（腾讯云统一 LLM 网关，开箱即用）： - `LLMConfig.LLMType`：`openai`（固定） - `LLMConfig.Model`：`deepseek-v4-flash`（推荐） - `LLMConfig.APIUrl`： - 国际站：`https://tokenhub-intl.tencentcloudmaas.com/v1/chat/completions` - 中国站：`https://tokenhub.tencentmaas.com/v1/chat/completions` - `LLMConfig.APIKey`：在 TokenHub 控制台获取（[国际站](https://console.intl.cloud.tencent.com/tokenhub) | [中国站](https://console.cloud.tencent.com/tokenhub)） - 也支持其他兼容 OpenAI 协议的 LLM，参考配置指南：[国际站](https://trtc.io/document/68338?product=conversationalai) | [中国站](https://cloud.tencent.com/document/product/647/115413) **检查点**：确认用户看到 `✓ 所有密钥已配置完成！`。如果有跳过项，提醒手动编辑 `env.yaml`。 > **提示用户**：以上为最小必填项，启动成功后还有丰富的可定制选项（角色定制、商城名称、欢迎语、TTS 音色、关键词等），详见 Step 4。 #### Step 3: 启动验证 启动脚本会自动创建虚拟环境、安装依赖、启动服务。 **验证标准**（告知用户逐项确认）： 1. 终端显示 `🚀 启动 TRTC AI 智能客服` + 访问地址（公网服务器会自动检测公网 IP 并启用 HTTPS，显示 `https://<公网IP>:8080`；本地开发则显示 `http://localhost:8080`） 2. 浏览器打开页面 → 能看到客服头像选择界面 3. 选择客服 → 点击"开始对话" → 听到 AI 播报欢迎语 4. 说话或打字 → AI 能正常回复 #### Step 4: 定制化（可选） 启动成功后，**主动告知用户**以下所有可定制项，引导按需修改： | 定制项 | 修改位置 | 说明 | |--------|---------|------| | **AI 客服角色** | 交互式引导 [4/4]（删除 `env.yaml` 后重新运行 `./start.sh`），或直接编辑 `env.yaml` → `SystemPrompt` / `SystemPromptYue` / `SystemPromptEn` | 预设品类 + 语气快速定制（见下方枚举表），也可手动编辑三语 SystemPrompt 做精细调整 | | **商城名称/品牌** | scaffold 的 `--name` / `--name-en` 参数 | 一键替换全链路品牌文案（三语 SystemPrompt、欢迎语、告别语、前端 UI） | | **欢迎语 / 告别语** | `env.yaml` → `WelcomeMessage` / `FarewellMessage`（`.zh` / `.yue` / `.en`） | AI 进房首条播报 / 关键词触发结束时播报 | | **LLM 模型** | `env.yaml` → `LLMConfig.Model` / `APIUrl` / `APIKey` | LLM 配置指南：[国际站](https://trtc.io/document/68338?product=conversationalai) \| [中国站](https://cloud.tencent.com/document/product/647/115413) | | **TTS 音色** | `config_loader.py` → `TTS_VOICE_MAP` | TTS 音色配置指南：[国际站](https://trtc.io/document/79682?product=conversationalai) \| [中国站](https://cloud.tencent.com/document/product/647/115414) | | **商品/订单数据** | `static/mock-orders.json`，或参考 `references/frontend-guide.md` 对接真实 API | JSON 格式含三语名称和价格，可替换为真实订单系统 | | **数字人** | `env.yaml` → `AvatarConfig` 三项 | 三项全填启用数字人视频模式，否则纯语音 | | **部署方式** | 自动检测公网 IP 启用 HTTPS；生产环境用 Gunicorn + Nginx + 正式证书 | WebRTC 要求 HTTPS；生产还需添加 `/action` 接口鉴权 | **AI 客服角色预设枚举值**（`start.sh` 交互式引导 [4/4]）： 商城品类（影响 AI 的专业知识方向）： | 选项 | 品类 | AI 擅长方向 | |------|------|------------| | 1 | 综合电商（默认） | 通用电商场景，不修改 SystemPrompt | | 2 | 数码产品 | 参数对比、兼容性问题、保修政策、使用教程 | | 3 | 服装鞋帽 | 尺码推荐、面料材质、搭配建议、洗涤保养、退换尺码 | | 4 | 食品生鲜 | 保质期、储存方式、配送时效、食材产地、过敏原信息 | | 5 | 家居百货 | 商品尺寸规格、安装方式、材质说明、配送安装服务 | 客服语气风格（影响 AI 的表达方式）： | 选项 | 风格 | 效果 | |------|------|------| | 1 | 亲切自然（默认） | 像朋友聊天，已内置于默认 SystemPrompt | | 2 | 专业严谨 | 用词准确规范，适合高端品牌/B2B | | 3 | 活泼可爱 | 轻松表达方式，适合年轻用户群体 | > 选择后自动注入三语 SystemPrompt（中文/粤语/英文同步）。如需更精细定制，直接编辑 `env.yaml` 中的 SystemPrompt 即可。 ### 路径 B：为现有项目集成 TRTC AI 对话 #### Step 1: 了解现有架构 询问并确认： - 后端语言和框架（Python/Node/Go/Java？） - 前端技术栈（React/Vue/原生 JS？已有 TRTC SDK？） - 集成范围：仅后端 API？还是含前端 UI？ #### Step 2: 按需读取参考文档并输出代码 根据用户技术栈，读取对应文档并**直接输出可集成的代码片段**： | 需求 | 读取文档 | 输出内容 | |------|----------|----------| | 后端 API | `references/architecture.md` | 用户语言的 5 个 Action 处理器代码（join / Start / Stop / Farewell / Transfer） | | 配置体系 | `references/config-guide.md` | 生成 `env.yaml` 模板 + 配置加载代码 | | 前端对话 UI | `references/frontend-guide.md` | TRTC SDK 进房 + 消息监听 + 字幕渲染代码 | **关键**：如果用户不是 Python 技术栈，需要将参考文档中的 Python 逻辑**翻译为用户的语言**（如 Node.js / Go / Java），核心逻辑不变。 #### Step 3: 验证集成 引导用户完成最小可用流程并逐步确认： 1. 后端 `/action` 接口能正常响应（`curl -X POST /action -H "Action: join"` 返回 UserSig） 2. 前端成功进入 TRTC 房间（控制台无报错） 3. `StartAIConversation` 调用成功返回 TaskId 4. 用户说话 → 听到 AI 回复（完整链路跑通） **如果卡在某一步**，参照下方 FAQ 表逐条排查。 --- ## 常见问题排查 当用户遇到问题时，按以下清单排查： | 现象 | 原因 | 解决方案 | |------|------|----------| | scaffold.py 报错退出 | Python 版本或参数错误 | 确认 Python 3.8+；检查输出目录路径是否合法 | | `start.sh` 报 Python 版本不够 | Python < 3.8 | 安装 Python 3.8+ | | venv 创建失败 | 缺少 `python3-venv` 包 | Ubuntu/Debian: `sudo apt install python3-venv`；macOS 自带 | | 依赖安装失败 | 网络问题 | `start.sh` 会自动 fallback 官方源；或手动 `pip install -r requirements.txt` | | `env.yaml` 解析报错 | YAML 缩进或格式错误 | 用在线 YAML 校验器检查；常见：冒号后缺空格、中文引号 | | 页面打开空白 | 静态文件缺失 | 确认 `static/app.js` 和 `templates/customer_service.html` 存在 | | 点"开始对话"无反应 | 密钥未填或填错 | 检查 `env.yaml` 中 SDKAPPID 不为 0、SECRET_ID/KEY 正确 | | 点"开始对话"提示**进房失败** | TRTC 进房参数异常 | 检查 `SDKAPPID` 是否正确填写；UserSig 是否校验失败（核对 `TRTC.SECRET`） | | 说话**无任何响应**（语音不可用） | 浏览器麦克风权限未授予或设备异常 | 检查浏览器地址栏麦克风权限；测试系统设备：录音机能否录到声音 | | 仅显示**本地字幕，AI 无回应** | LLM 服务异常 | 检查 `LLMConfig.APIKey` / `APIUrl` / `Model` 是否正确；确认 LLM 账户额度充足 | | AI 有字幕但**无语音播报** | TTS 服务异常 | 核对 TTS 参数（VoiceId、Language）；确认 TTS 套餐包资源充足 | | LLM **长时间不回复**或超时报错 | LLM Timeout | 调大 `env.yaml` → `LLMConfig.Timeout`（如 5.0 → 10.0） | | 进房成功但无欢迎语 | LLM APIKey 错误或 TRTC 服务未开通 | 检查 `LLMConfig.APIKey`；确认 TRTC 控制台已开通 AI 对话能力 | | 非 localhost 访问**无声音或麦克风不可用** | WebRTC 安全策略要求 HTTPS | 运行 `./start.sh --https` 自动生成自签证书并启用 HTTPS；首次访问浏览器点击"高级→继续前往" | | 公网 IP 访问**页面打不开** | 防火墙未放行端口 | 确认服务器防火墙/安全组已放行 8080 端口（TCP）| | 端口 8080 被占用 | 其他进程占用 | `start.sh` 会自动检测并询问是否终止 | | 浏览器控制台报 CORS 错误 | 前后端不同源 | 确保前端页面由 Flask 提供（同源）；不要用 `file://` 打开 HTML | --- ## 速查参考 ### 后端 API（`POST /action` + `Action` 请求头） | Action | 职责 | |--------|------| | `join` | 签发 UserSig（用户/机器人/数字人），下发关键词/告别语/数字人开关 | | `StartAIConversation` | 组装参数调用 TRTC OpenAPI 启动 AI 对话 | | `StopAIConversation` | 兜底停止（正常走 FarewellAndStop） | | `FarewellAndStop` | 推送告别语 + StopAfterPlay 一站式结束 | | `TransferAndStop` | 推送转接提示语 + StopAfterPlay 一站式结束 | ### 核心设计模式（详见 `references/architecture.md`） | 模式 | 要点 | |------|------| | StopAfterPlay 一站式结束 | `ControlAIConversation` + `StopAfterPlay=true`，TTS 播完自动停止 | | 文字输入跳过 ASR | `type: 20000` 自定义消息直送 LLM | | 中英文词边界匹配 | 中文用非中文字符边界，英文用 `\b` | | 增量/累积自适应字幕 | 自动检测 TRTC 下发模式 | | 机器人退房 + AI 状态双保险 | `REMOTE_USER_LEAVE` + `state=5` | | 数字人可选降级 | `AvatarConfig` 三项齐全启用，否则纯语音 | ### 技术栈 Python 3.8+ · Flask · tencentcloud-sdk-python · 原生 JS · TRTC Web SDK v5 · YAML（envyaml） ### 安全 - `env.yaml` 含密钥，加入 `.gitignore`，切勿提交 - UserSig 服务端签发，密钥不暴露给前端 - 生产环境添加 `/action` 接口鉴权 - 非 localhost 部署必须 HTTPS（WebRTC 安全策略）

path: README.md
content: # TRTC AI Customer Service Skill

English | [简体中文](README_ZH.md)

> Rapidly scaffold a production-ready AI customer service Web application powered by [Tencent RTC Conversational AI](https://trtc.io/solutions/conversational-ai) — voice & text dual-mode, trilingual, with built-in e-commerce workflows.

## Highlights

| Capability | Description |
|-----------|-------------|
| **Real-time Voice** | Bidirectional WebRTC audio between end-user and cloud-hosted AI Bot |
| **Text Fallback** | Bypass ASR — send typed messages directly to the LLM pipeline |
| **Trilingual i18n** | Chinese / Cantonese / English across UI, STT, TTS, and SystemPrompt |
| **E-commerce Workflows** | Order inquiry, returns & exchanges, shipping tracking, promotions |
| **Digital Avatar** | Optional virtual human rendering; graceful degradation to pure voice |
| **Session Lifecycle** | Keyword-triggered farewell, human agent transfer, auto-idle timeout |
| **Service Rating** | 4-dimension post-session evaluation |

## Installation

This is a **portable Agent Skill** (a `SKILL.md` + `scripts/` + `references/` + `assets/` bundle). Install it to your tool's skills directory and the agent will auto-discover it.

### OpenClaw

Choose the install location based on your needs:

| Location | Scope | Command |
|----------|-------|---------|
| `<workspace>/skills/` | Current workspace, highest priority | `git clone https://github.com/jerryang-cool/trtc-ai-customer-service-skill.git skills/trtc-ai-customer-service` |
| `~/.agents/skills/` | Personal, effective across workspaces | `git clone https://github.com/jerryang-cool/trtc-ai-customer-service-skill.git ~/.agents/skills/trtc-ai-customer-service` |
| `~/.openclaw/skills/` | Global, visible to all agents | `git clone https://github.com/jerryang-cool/trtc-ai-customer-service-skill.git ~/.openclaw/skills/trtc-ai-customer-service` |

Invoke via natural language ("help me build an AI customer service") or slash command `/trtc-ai-customer-service`.

> See [OpenClaw Skills documentation](https://docs.openclaw.ai/tools/skills) for more details.

### CodeBuddy

Settings → Skills → **Import Skill**, then point to this repository's directory. Once imported, mention keywords such as *"AI customer service"*, *"e-commerce support"*, or *"TRTC + AI"* to activate.

### Claude Code

```bash
# User-level (available across all projects)
git clone <repo-url> ~/.claude/skills/trtc-ai-customer-service

# OR project-level (commit to repo, share with team)
git clone <repo-url> .claude/skills/trtc-ai-customer-service
```

Claude Code auto-discovers skills via `description` matching. Invoke implicitly ("help me build an AI customer service") or explicitly via `/trtc-ai-customer-service`.

### OpenAI Codex CLI

```bash
# User-level
git clone <repo-url> ~/.codex/skills/trtc-ai-customer-service

# OR project-level
git clone <repo-url> .agents/skills/trtc-ai-customer-service
```

Codex auto-detects new skills on next session. Invoke implicitly via natural language, or explicitly via `$trtc-ai-customer-service`.

### Cursor

```bash
# Skill bundle: drop into Cursor's skills directory
git clone <repo-url> ~/.cursor/skills/trtc-ai-customer-service
```

The `.cursor/rules/trtc-ai-customer-service.mdc` inside this repo follows the 2026 MDC format (Agent Requested mode) — Cursor's Agent loads it on demand based on the description.

> **Legacy `.cursorrules` users**: Cursor silently ignores `.cursorrules` in Agent mode since 2026. This skill ships only the modern `.cursor/rules/*.mdc` format.

## Quick Start

```bash
# 1. Generate a new project
python scripts/scaffold.py ./my-shop --name "CloudShop" --name-en "CloudShop Mall"

# 2. Launch (first run enters interactive credential wizard)
cd ./my-shop && ./start.sh

# 3. Open http://localhost:8080
```

## Architecture

```
┌─────────────────────────────────┐
│  Browser  (TRTC Web SDK v5)     │
│  WebRTC audio + custom messages │
└──────────────┬──────────────────┘
               │
       ┌───────▼───────┐
       │   TRTC Room   │
       │  ASR → LLM →  │  Cloud-hosted AI pipeline
       │      TTS       │  (zero LLM calls on your server)
       └───────┬────────┘
               │ OpenAPI (TC3-HMAC-SHA256)
       ┌───────▼────────┐
       │  Flask Backend  │  UserSig signing
       │   (app.py)      │  + OpenAPI relay only
       └─────────────────┘
```

## Tech Stack

| Layer | Technology |
|-------|-----------|
| Backend | Python 3.8+ · Flask · tencentcloud-sdk-python |
| Frontend | Vanilla JS · TRTC Web SDK v5 · CSS custom properties |
| AI Engine | TRTC ConversationAI (cloud ASR → LLM → TTS) |
| Auth | HMAC-SHA256 UserSig (server-side only) |
| Config | YAML via `envyaml` with env-var interpolation |

## Project Layout

```
trtc-ai-customer-service-skill/
├── SKILL.md              # Skill entry — used by CodeBuddy / Claude Code / Codex CLI
├── .cursor/
│   └── rules/
│       └── trtc-ai-customer-service.mdc  # Cursor 2026 MDC format
├── LICENSE               # MIT
├── CHANGELOG.md          # Version history
├── README.md             # English documentation (this file)
├── README_ZH.md          # 中文文档
├── scripts/
│   └── scaffold.py       # One-command project generator
├── references/
│   ├── architecture.md   # System design & backend integration
│   ├── config-guide.md   # Full configuration reference
│   └── frontend-guide.md # TRTC Web SDK integration guide
└── assets/               # Template files shipped by scaffold
```

## References

- [Tencent RTC Console](https://console.trtc.io/)
- [Conversational AI Solution](https://trtc.io/solutions/conversational-ai)
- [LLM Configuration Guide](https://trtc.io/document/68338?product=conversationalai)
- [TTS Voice Configuration](https://trtc.io/document/79682?product=conversationalai)

## License

Released under the [MIT License](LICENSE).


path: README_ZH.md
content: # TRTC AI 电商客服 Skill

[English](README.md) | 简体中文

> 基于 [腾讯云 TRTC Conversational AI 解决方案](https://cloud.tencent.com/document/product/647/110584) 快速搭建生产级 AI 电商客服 Web 应用 — 语音与文字双通道、三语国际化、内置电商业务流程。

## 核心能力

| 能力 | 说明 |
|------|------|
| **实时语音** | 终端用户与云端 AI Bot 之间的双向 WebRTC 音频通信 |
| **文字降级** | 跳过 ASR，将键盘输入直接送达 LLM 处理链路 |
| **三语国际化** | 中文 / 粤语 / 英文全覆盖：UI、STT、TTS、SystemPrompt |
| **电商业务流** | 订单查询、退换货处理、物流追踪、优惠活动咨询 |
| **数字人可选** | 可选虚拟形象渲染，未配置时优雅降级为纯语音 |
| **会话生命周期** | 关键词触发告别、转人工、空闲超时自动结束 |
| **服务评分** | 会话结束后 4 维度评价 |

## 安装方式

本项目是一个**便携式 Agent Skill**（包含 `SKILL.md` + `scripts/` + `references/` + `assets/` 的标准结构）。安装到对应工具的 skills 目录即可被自动发现。

### OpenClaw

根据需要选择安装位置：

| 位置 | 作用域 | 命令 |
|------|--------|------|
| `<workspace>/skills/` | 当前工作区，优先级最高 | `git clone https://github.com/jerryang-cool/trtc-ai-customer-service-skill.git skills/trtc-ai-customer-service` |
| `~/.agents/skills/` | 个人级，跨工作区生效 | `git clone https://github.com/jerryang-cool/trtc-ai-customer-service-skill.git ~/.agents/skills/trtc-ai-customer-service` |
| `~/.openclaw/skills/` | 全局共享，所有 agent 可见 | `git clone https://github.com/jerryang-cool/trtc-ai-customer-service-skill.git ~/.openclaw/skills/trtc-ai-customer-service` |

安装后通过自然语言（如"帮我做个 AI 客服"）或斜杠命令 `/trtc-ai-customer-service` 调用。

> 更多信息参见 [OpenClaw Skills 官方文档](https://docs.openclaw.ai/zh-CN/tools/skills)。

### CodeBuddy

设置 → Skills → **导入 Skill**，选择本仓库目录。导入后，在对话中提及 *"AI 客服"*、*"电商客服"*、*"TRTC + AI"* 等关键词即自动激活。

### Claude Code

```bash
# 用户级（跨项目可用）
git clone <repo-url> ~/.claude/skills/trtc-ai-customer-service

# 或项目级（提交到仓库，团队共享）
git clone <repo-url> .claude/skills/trtc-ai-customer-service
```

Claude Code 通过 `description` 字段自动匹配触发。隐式调用（如"帮我做个 AI 客服"）或显式调用 `/trtc-ai-customer-service` 均可。

### OpenAI Codex CLI

```bash
# 用户级
git clone <repo-url> ~/.codex/skills/trtc-ai-customer-service

# 或项目级
git clone <repo-url> .agents/skills/trtc-ai-customer-service
```

Codex 在下次会话启动时自动检测新 skill。隐式调用（自然语言）或显式调用 `$trtc-ai-customer-service` 均可。

### Cursor

```bash
# 投放到 Cursor skills 目录
git clone <repo-url> ~/.cursor/skills/trtc-ai-customer-service
```

仓库内 `.cursor/rules/trtc-ai-customer-service.mdc` 遵循 2026 MDC 格式（Agent Requested 模式），Cursor 的 Agent 模式会按 description 按需加载。

> **从 `.cursorrules` 升级**：Cursor 自 2026 年起在 Agent 模式下静默忽略 `.cursorrules`。本 Skill 仅提供新的 `.cursor/rules/*.mdc` 格式。

## 快速开始

```bash
# 1. 生成新项目
python scripts/scaffold.py ./my-shop --name "云尚商城"

# 2. 启动（首次运行进入交互式密钥配置向导）
cd ./my-shop && ./start.sh

# 3. 浏览器访问 http://localhost:8080
```

## 架构

```
┌─────────────────────────────────┐
│  浏览器  (TRTC Web SDK v5)       │
│  WebRTC 音频 + 自定义消息         │
└──────────────┬──────────────────┘
               │
       ┌───────▼───────┐
       │   TRTC 房间    │
       │  ASR → LLM →  │  云端 AI 处理链路
       │      TTS       │  (后端零 LLM 调用)
       └───────┬────────┘
               │ OpenAPI (TC3-HMAC-SHA256)
       ┌───────▼────────┐
       │  Flask 后端     │  UserSig 签发
       │   (app.py)      │  + OpenAPI 中转
       └─────────────────┘
```

## 技术栈

| 层 | 技术 |
|----|------|
| 后端 | Python 3.8+ · Flask · tencentcloud-sdk-python |
| 前端 | 原生 JS · TRTC Web SDK v5 · CSS 自定义属性 |
| AI 引擎 | TRTC ConversationAI（云端 ASR → LLM → TTS） |
| 鉴权 | HMAC-SHA256 UserSig（仅服务端签发） |
| 配置 | YAML，通过 `envyaml` 支持环境变量插值 |

## 项目结构

```
trtc-ai-customer-service-skill/
├── SKILL.md              # Skill 入口 — 供 CodeBuddy / Claude Code / Codex CLI 识别
├── .cursor/
│   └── rules/
│       └── trtc-ai-customer-service.mdc  # Cursor 2026 MDC 格式
├── LICENSE               # MIT 许可证
├── CHANGELOG.md          # 版本历史
├── README.md             # English documentation
├── README_ZH.md          # 中文文档（本文件）
├── scripts/
│   └── scaffold.py       # 一键项目生成器
├── references/
│   ├── architecture.md   # 系统设计与后端集成
│   ├── config-guide.md   # 完整配置参考
│   └── frontend-guide.md # TRTC Web SDK 集成指南
└── assets/               # 脚手架附带的模板文件
```

## 参考链接

- [TRTC 控制台](https://console.cloud.tencent.com/trtc/app)
- [Conversational AI 解决方案](https://cloud.tencent.com/document/product/647/110584)
- [LLM 配置指南](https://cloud.tencent.com/document/product/647/115413)
- [TTS 音色配置指南](https://cloud.tencent.com/document/product/647/115414)

## 许可证

基于 [MIT 许可证](LICENSE) 发布。


path: assets/TLSSigAPIv2.py
content: #!/usr/bin/python
# coding:utf-8

import hmac
import hashlib
import base64
import zlib
import json
import time

def base64_encode_url(data):
    """ base url encode 实现"""
    base64_data = base64.b64encode(data)
    base64_data_str = bytes.decode(base64_data)
    base64_data_str = base64_data_str.replace('+', '*')
    base64_data_str = base64_data_str.replace('/', '-')
    base64_data_str = base64_data_str.replace('=', '_')
    return base64_data_str

def base64_decode_url(base64_data):
    """ base url decode 实现"""
    base64_data_str = bytes.decode(base64_data)
    base64_data_str = base64_data_str.replace('*', '+')
    base64_data_str = base64_data_str.replace('-', '/')
    base64_data_str = base64_data_str.replace('_', '=')
    raw_data = base64.b64decode(base64_data_str)
    return raw_data

class TLSSigAPIv2:
    __sdkappid = 0
    __version = '2.0'
    __key = ""

    def __init__(self, sdkappid, key):
        self.__sdkappid = sdkappid
        self.__key = key

    ##用于生成实时音视频(TRTC)业务进房权限加密串,具体用途用法参考TRTC文档：https://cloud.tencent.com/document/product/647/32240
    # TRTC业务进房权限加密串需使用用户定义的userbuf
    # @brief 生成 userbuf
    # @param account 用户名
    # @param dwSdkappid sdkappid
    # @param dwAuthID 数字房间号
    # @param dwExpTime 过期时间：该权限加密串的过期时间，实际过期时间：当前时间+dwExpTime
    # @param dwPrivilegeMap 用户权限，255表示所有权限
    # @param dwAccountType 用户类型,默认为0
    # @param roomStr 字符串房间号,数字房间号非0时存在
    # @return userbuf {string} 返回的userbuf
    #/
    ##It is used to generate real-time audio and video (TRTC) business access rights encryption string. For specific usage, please refer to the TRTC document：https://cloud.tencent.com/document/product/647/32240
    # User-defined userbuf is used for the encrypted string of TRTC service entry permission
    # @brief generate userbuf
    # @param account username
    # @param dwSdkappid sdkappid
    # @param dwAuthID digital room number
    # @param dwExpTime Expiration time: The expiration time of the encrypted string of this permission. Expiration time = now+dwExpTime
    # @param dwPrivilegeMap User permissions, 255 means all permissions
    # @param dwAccountType User type, default is 0
    # @param roomStr String room number
    # @return userbuf string returned userbuf
    #/
    def _gen_userbuf(self, account, dwAuthID, dwExpTime, dwPrivilegeMap, dwAccountType, roomStr):
        userBuf = b''
        if len(roomStr) > 0 :
            userBuf += bytearray([1])
        else :
            userBuf += bytearray([0])
        userBuf += bytearray([
            ((len(account) & 0xFF00) >> 8),
            (len(account) & 0x00FF),
        ])
        userBuf += bytearray(map(ord, account))

        # dwSdkAppid
        userBuf += bytearray([
            ((self.__sdkappid & 0xFF000000) >> 24),
            ((self.__sdkappid & 0x00FF0000) >> 16),
            ((self.__sdkappid & 0x0000FF00) >> 8),
            (self.__sdkappid & 0x000000FF),
        ])

        # dwAuthId
        userBuf += bytearray([
            ((dwAuthID & 0xFF000000) >> 24),
            ((dwAuthID & 0x00FF0000) >> 16),
            ((dwAuthID & 0x0000FF00) >> 8),
            (dwAuthID & 0x000000FF),
        ])

        # dwExpTime = now + 300; expire = dwExpTime +int(time.time())
        expire = dwExpTime +int(time.time())
        userBuf += bytearray([
            ((expire & 0xFF000000) >> 24),
            ((expire & 0x00FF0000) >> 16),
            ((expire & 0x0000FF00) >> 8),
            (expire & 0x000000FF),
        ])

        # dwPrivilegeMap
        userBuf += bytearray([
            ((dwPrivilegeMap & 0xFF000000) >> 24),
            ((dwPrivilegeMap & 0x00FF0000) >> 16),
            ((dwPrivilegeMap & 0x0000FF00) >> 8),
            (dwPrivilegeMap & 0x000000FF),
        ])

        # dwAccountType
        userBuf += bytearray([
            ((dwAccountType & 0xFF000000) >> 24),
            ((dwAccountType & 0x00FF0000) >> 16),
            ((dwAccountType & 0x0000FF00) >> 8),
            (dwAccountType & 0x000000FF),
        ])

        if len(roomStr) > 0 :
            userBuf += bytearray([
                ((len(roomStr) & 0xFF00) >> 8),
                (len(roomStr) & 0x00FF),
            ])
            userBuf += bytearray(map(ord, roomStr))

        return userBuf

    def __hmacsha256(self, identifier, curr_time, expire, base64_userbuf=None):
        """ 通过固定串进行 hmac 然后 base64 得的 sig 字段的值"""
        raw_content_to_be_signed = "TLS.identifier:" + str(identifier) + "\n"\
                + "TLS.sdkappid:" + str(self.__sdkappid) + "\n"\
                + "TLS.time:" + str(curr_time) + "\n"\
                + "TLS.expire:" + str(expire) + "\n"
        if None != base64_userbuf:
            raw_content_to_be_signed += "TLS.userbuf:" + base64_userbuf + "\n"
        return base64.b64encode(hmac.new(self.__key.encode('utf-8'), raw_content_to_be_signed.encode('utf-8'), hashlib.sha256).digest())

    def __gen_sig(self, identifier, expire=180*86400, userbuf=None):
        """ 用户可以采用默认的有效期生成 sig """
        curr_time = int(time.time())
        m = dict()
        m["TLS.ver"] = self.__version
        m["TLS.identifier"] = str(identifier)
        m["TLS.sdkappid"] = int(self.__sdkappid)
        m["TLS.expire"] = int(expire)
        m["TLS.time"] = int(curr_time)
        base64_userbuf = None
        if None != userbuf:
            base64_userbuf = bytes.decode(base64.b64encode(userbuf))
            m["TLS.userbuf"] = base64_userbuf
        m["TLS.sig"] = bytes.decode(self.__hmacsha256(identifier, curr_time, expire, base64_userbuf))
        raw_sig = json.dumps(m)
        sig_cmpressed = zlib.compress(raw_sig.encode('utf-8'))
        base64_sig = base64_encode_url(sig_cmpressed)
        return base64_sig

    ##
    #【功能说明】用于签发 TRTC 和 IM 服务中必须要使用的 UserSig 鉴权票据
    #
    #【参数说明】
    # userid - 用户id，限制长度为32字节，只允许包含大小写英文字母（a-zA-Z）、数字（0-9）及下划线和连词符。
    # expire - UserSig 票据的过期时间，单位是秒，比如 86400 代表生成的 UserSig 票据在一天后就无法再使用了。
    #/
    # Function: Used to issue UserSig that is required by the TRTC and IM services.
    # Parameter description:
    # userid - User ID. The value can be up to 32 bytes in length and contain letters (a-z and A-Z), digits (0-9), underscores (_), and hyphens (-).
    # expire - UserSig expiration time, in seconds. For example, 86400 indicates that the generated UserSig will expire one day after being generated.
    def genUserSig(self, userid, expire=180*86400):
        """ 用户可以采用默认的有效期生成 sig """
        return self.__gen_sig(userid, expire, None)

    # 兼容旧版本的方法名
    def gen_sig(self, userid, expire=180*86400):
        """ 兼容旧版本，用户可以采用默认的有效期生成 sig """
        return self.genUserSig(userid, expire)

    ##
    #【功能说明】
    # 用于签发 TRTC 进房参数中可选的 PrivateMapKey 权限票据。
    # PrivateMapKey 需要跟 UserSig 一起使用，但比 UserSig 有更强的权限控制能力：
    # - UserSig 只能控制某个 UserID 有无使用 TRTC 服务的权限，只要 UserSig 正确，其对应的 UserID 可以进出任意房间。
    # - PrivateMapKey 则是将 UserID 的权限控制的更加严格，包括能不能进入某个房间，能不能在该房间里上行音视频等等。
    # 如果要开启 PrivateMapKey 严格权限位校验，需要在【实时音视频控制台】=>【应用管理】=>【应用信息】中"启动权限密钥"。
    #
    #【参数说明】
    # userid - 用户id，限制长度为32字节，只允许包含大小写英文字母（a-zA-Z）、数字（0-9）及下划线和连词符。
    # expire - PrivateMapKey 票据的过期时间，单位是秒，比如 86400 生成的 PrivateMapKey 票据在一天后就无法再使用了。
    # roomid - 房间号，用于指定该 userid 可以进入的房间号
    # privilegeMap - 权限位，使用了一个字节中的 8 个比特位，分别代表八个具体的功能权限：
    #   - 第 1 位：0000 0001 = 1，创建房间的权限
    #   - 第 2 位：0000 0010 = 2，加入房间的权限
    #   - 第 3 位：0000 0100 = 4，发送语音的权限
    #   - 第 4 位：0000 1000 = 8，接收语音的权限
    #   - 第 5 位：0001 0000 = 16，发送视频的权限
    #   - 第 6 位：0010 0000 = 32，接收视频的权限
    #   - 第 7 位：0100 0000 = 64，发送辅路（也就是屏幕分享）视频的权限
    #   - 第 8 位：1000 0000 = 200，接收辅路（也就是屏幕分享）视频的权限
    #   - privilegeMap == 1111 1111 == 255 代表该 userid 在该 roomid 房间内的所有功能权限。
    #   - privilegeMap == 0010 1010 == 42 代表该 userid 拥有加入房间和接收音视频数据的权限，但不具备其他权限。
    #/
    # Function:
    # Used to issue PrivateMapKey that is optional for room entry.
    # PrivateMapKey must be used together with UserSig but with more powerful permission control capabilities.
    # - UserSig can only control whether a UserID has permission to use the TRTC service. As long as the UserSig is correct, the user with the corresponding UserID can enter or leave any room.
    # - PrivateMapKey specifies more stringent permissions for a UserID, including whether the UserID can be used to enter a specific room and perform audio/video upstreaming in the room.
    # To enable stringent PrivateMapKey permission bit verification, you need to enable permission key in TRTC console > Application Management > Application Info.
    # *
    # Parameter description:
    # userid - User ID. The value can be up to 32 bytes in length and contain letters (a-z and A-Z), digits (0-9), underscores (_), and hyphens (-).
    # roomid - ID of the room to which the specified UserID can enter.
    # expire - PrivateMapKey expiration time, in seconds. For example, 86400 indicates that the generated PrivateMapKey will expire one day after being generated.
    # privilegeMap - Permission bits. Eight bits in the same byte are used as the permission switches of eight specific features:
    #   - Bit 1: 0000 0001 = 1, permission for room creation
    #   - Bit 2: 0000 0010 = 2, permission for room entry
    #   - Bit 3: 0000 0100 = 4, permission for audio sending
    #   - Bit 4: 0000 1000 = 8, permission for audio receiving
    #   - Bit 5: 0001 0000 = 16, permission for video sending
    #   - Bit 6: 0010 0000 = 32, permission for video receiving
    #   - Bit 7: 0100 0000 = 64, permission for substream video sending (screen sharing)
    #   - Bit 8: 1000 0000 = 200, permission for substream video receiving (screen sharing)
    #   - privilegeMap == 1111 1111 == 255: Indicates that the UserID has all feature permissions of the room specified by roomid.
    #   - privilegeMap == 0010 1010 == 42: Indicates that the UserID has only the permissions to enter the room and receive audio/video data.
    def genPrivateMapKey(self, userid, expire, roomid, privilegeMap):
        """ 带 userbuf 生成签名 """
        userbuf = self._gen_userbuf(userid,roomid,expire,privilegeMap,0,"")
        return self.__gen_sig(userid, expire, userbuf)

    ##
    #【功能说明】
    # 用于签发 TRTC 进房参数中可选的 PrivateMapKey 权限票据。
    # PrivateMapKey 需要跟 UserSig 一起使用，但比 UserSig 有更强的权限控制能力：
    # - UserSig 只能控制某个 UserID 有无使用 TRTC 服务的权限，只要 UserSig 正确，其对应的 UserID 可以进出任意房间。
    # - PrivateMapKey 则是将 UserID 的权限控制的更加严格，包括能不能进入某个房间，能不能在该房间里上行音视频等等。
    # 如果要开启 PrivateMapKey 严格权限位校验，需要在【实时音视频控制台】=>【应用管理】=>【应用信息】中"启动权限密钥"。
    #
    #【参数说明】
    # userid - 用户id，限制长度为32字节，只允许包含大小写英文字母（a-zA-Z）、数字（0-9）及下划线和连词符。
    # expire - PrivateMapKey 票据的过期时间，单位是秒，比如 86400 生成的 PrivateMapKey 票据在一天后就无法再使用了。
    # roomstr - 字符串房间号，用于指定该 userid 可以进入的房间号
    # privilegeMap - 权限位，使用了一个字节中的 8 个比特位，分别代表八个具体的功能权限：
    #   - 第 1 位：0000 0001 = 1，创建房间的权限
    #   - 第 2 位：0000 0010 = 2，加入房间的权限
    #   - 第 3 位：0000 0100 = 4，发送语音的权限
    #   - 第 4 位：0000 1000 = 8，接收语音的权限
    #   - 第 5 位：0001 0000 = 16，发送视频的权限
    #   - 第 6 位：0010 0000 = 32，接收视频的权限
    #   - 第 7 位：0100 0000 = 64，发送辅路（也就是屏幕分享）视频的权限
    #   - 第 8 位：1000 0000 = 200，接收辅路（也就是屏幕分享）视频的权限
    #   - privilegeMap == 1111 1111 == 255 代表该 userid 在该 roomid 房间内的所有功能权限。
    #   - privilegeMap == 0010 1010 == 42 代表该 userid 拥有加入房间和接收音视频数据的权限，但不具备其他权限。
    #/
    # Function:
    # Used to issue PrivateMapKey that is optional for room entry.
    # PrivateMapKey must be used together with UserSig but with more powerful permission control capabilities.
    # - UserSig can only control whether a UserID has permission to use the TRTC service. As long as the UserSig is correct, the user with the corresponding UserID can enter or leave any room.
    # - PrivateMapKey specifies more stringent permissions for a UserID, including whether the UserID can be used to enter a specific room and perform audio/video upstreaming in the room.
    # To enable stringent PrivateMapKey permission bit verification, you need to enable permission key in TRTC console > Application Management > Application Info.
    # *
    # Parameter description:
    # @param userid - User ID. The value can be up to 32 bytes in length and contain letters (a-z and A-Z), digits (0-9), underscores (_), and hyphens (-).
    # @param roomstr - ID of the room to which the specified UserID can enter.
    # @param expire - PrivateMapKey expiration time, in seconds. For example, 86400 indicates that the generated PrivateMapKey will expire one day after being generated.
    # @param privilegeMap - Permission bits. Eight bits in the same byte are used as the permission switches of eight specific features:
    #   - Bit 1: 0000 0001 = 1, permission for room creation
    #   - Bit 2: 0000 0010 = 2, permission for room entry
    #   - Bit 3: 0000 0100 = 4, permission for audio sending
    #   - Bit 4: 0000 1000 = 8, permission for audio receiving
    #   - Bit 5: 0001 0000 = 16, permission for video sending
    #   - Bit 6: 0010 0000 = 32, permission for video receiving
    #   - Bit 7: 0100 0000 = 64, permission for substream video sending (screen sharing)
    #   - Bit 8: 1000 0000 = 200, permission for substream video receiving (screen sharing)
    #   - privilegeMap == 1111 1111 == 255: Indicates that the UserID has all feature permissions of the room specified by roomid.
    #   - privilegeMap == 0010 1010 == 42: Indicates that the UserID has only the permissions to enter the room and receive audio/video data.
    def genPrivateMapKeyWithStringRoomID(self, userid, expire, roomstr, privilegeMap):
        """ 带 userbuf 生成签名 """
        userbuf = self._gen_userbuf(userid,0,expire,privilegeMap,0,roomstr)
        return self.__gen_sig(userid, expire, userbuf)




path: assets/start.sh
content: #!/usr/bin/env bash
# =====================================================================
# TRTC AI Customer Service - One-click Launcher (macOS / Linux)
# TRTC AI 智能客服 - 一键启动脚本 (macOS / Linux)
#
# Features / 功能：
#   1. Auto-detect Python version (>= 3.8) / 自动检测 Python 版本
#   2. Auto-create env.yaml from env.example.yaml / 自动生成配置文件
#   3. Auto-create & activate venv / 自动创建虚拟环境
#   4. Auto-install dependencies / 自动安装依赖
#   5. Default PyPI, fallback Tsinghua mirror / 默认官方源，回退清华镜像
#   6. Auto-detect locale for i18n output / 根据系统语言自动切换提示
#   7. Launch Flask server / 启动 Flask 服务
#
# Usage / 使用：
#   ./start.sh            # Start / 启动
#   ./start.sh --rebuild  # Force rebuild venv / 强制重建虚拟环境
# =====================================================================

set -e

# ---------------- Locale Detection / 语言检测 ----------------
_LOCALE="${LANG:-${LC_ALL:-en_US.UTF-8}}"
_IS_ZH=0
case "$_LOCALE" in
    zh_*|zh-*) _IS_ZH=1 ;;
esac

# i18n message helper
msg() {
    if [ "$_IS_ZH" -eq 1 ]; then
        echo "$2"
    else
        echo "$1"
    fi
}

# ---------------- Basic Variables / 基础变量 ----------------
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"

VENV_DIR="venv"
ENV_FILE="env.yaml"
ENV_EXAMPLE="env.example.yaml"
REQUIREMENTS="requirements.txt"
MIN_PY_MAJOR=3
MIN_PY_MINOR=8
PORT=8080

# Color output / 颜色输出
if [ -t 1 ]; then
    GREEN='\033[0;32m'
    YELLOW='\033[1;33m'
    RED='\033[0;31m'
    CYAN='\033[0;36m'
    BOLD='\033[1m'
    NC='\033[0m'
else
    GREEN=''; YELLOW=''; RED=''; CYAN=''; BOLD=''; NC=''
fi

log() { printf "${CYAN}[%s]${NC} %s\n" "$(date +%H:%M:%S)" "$*"; }
ok()  { printf "${GREEN}✓${NC} %s\n" "$*"; }
warn(){ printf "${YELLOW}⚠${NC}  %s\n" "$*"; }
err() { printf "${RED}✗${NC} %s\n" "$*" >&2; }
die() { err "$*"; exit 1; }

# ---------------- Argument Parsing / 参数解析 ----------------
REBUILD=0
for arg in "$@"; do
    case "$arg" in
        --rebuild) REBUILD=1 ;;
        --help|-h)
            msg "Usage: $0 [--rebuild]" "用法: $0 [--rebuild]"
            msg "  --rebuild  Delete existing venv and rebuild" "  --rebuild  删除现有 venv 并重建"
            exit 0
            ;;
    esac
done

# ---------------- Step 1: Detect Python / 检测 Python ----------------
log "$(msg "Detecting Python environment..." "检测 Python 环境...")"

PY_CMD=""
for cand in python3 python; do
    if command -v "$cand" >/dev/null 2>&1; then
        VER=$("$cand" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null || echo "0.0")
        MAJOR=$(echo "$VER" | cut -d. -f1)
        MINOR=$(echo "$VER" | cut -d. -f2)
        if [ "$MAJOR" -gt "$MIN_PY_MAJOR" ] || { [ "$MAJOR" -eq "$MIN_PY_MAJOR" ] && [ "$MINOR" -ge "$MIN_PY_MINOR" ]; }; then
            PY_CMD="$cand"
            ok "$(msg "Found Python $VER -> $(command -v "$cand")" "找到 Python $VER -> $(command -v "$cand")")"
            break
        fi
    fi
done

if [ -z "$PY_CMD" ]; then
    err "$(msg "Python >= ${MIN_PY_MAJOR}.${MIN_PY_MINOR} not found" "未检测到 Python >= ${MIN_PY_MAJOR}.${MIN_PY_MINOR}")"
    msg "  Please visit https://www.python.org/downloads/ to install" "  请前往 https://www.python.org/downloads/ 下载安装"
    exit 1
fi

# ---------------- Step 2: Detect env.yaml / 检测 env.yaml ----------------
if [ ! -f "$ENV_FILE" ]; then
    if [ ! -f "$ENV_EXAMPLE" ]; then
        die "$(msg "${ENV_EXAMPLE} not found, project files incomplete" "未找到 ${ENV_EXAMPLE}，项目文件不完整")"
    fi
    cp "$ENV_EXAMPLE" "$ENV_FILE"
    warn "$(msg "Created ${ENV_FILE} (copied from ${ENV_EXAMPLE})" "已创建 ${ENV_FILE}（从 ${ENV_EXAMPLE} 复制）")"
    echo ""
    printf "%b╔══════════════════════════════════════════════════╗%b\n" "$BOLD" "$NC"
    if [ "$_IS_ZH" -eq 1 ]; then
        printf "%b║          首次配置 - 分步填入密钥                  ║%b\n" "$BOLD" "$NC"
    else
        printf "%b║       First-time Setup - Enter Credentials       ║%b\n" "$BOLD" "$NC"
    fi
    printf "%b╚══════════════════════════════════════════════════╝%b\n" "$BOLD" "$NC"
    echo ""
    if [ "$_IS_ZH" -eq 1 ]; then
        echo "  需要准备 3 组密钥 + 1 项业务配置（约 5 分钟）。"
        echo "  每步都可以按 Enter 跳过，稍后手动编辑 ${ENV_FILE}。"
        echo ""
        echo "  ⚠ 注意：密钥将以明文存储在 ${ENV_FILE} 中，"
        echo "    请勿提交到版本控制，建议设置 chmod 600 ${ENV_FILE}。"
    else
        echo "  You'll need 3 sets of credentials + 1 business config (~5 min)."
        echo "  Press Enter at any step to skip, then edit ${ENV_FILE} manually."
        echo ""
        echo "  ⚠ Note: Credentials are stored in plaintext in ${ENV_FILE}."
        echo "    Do not commit to version control. Consider: chmod 600 ${ENV_FILE}"
    fi
    echo ""

    # --- 0/4 Deployment Region / 部署区域选择 ---
    printf "%b[0/4] $(msg "Deployment Region" "部署区域")%b\n" "$CYAN" "$NC"
    if [ "$_IS_ZH" -eq 1 ]; then
        printf "    1) 国际站 intl（默认）  2) 中国站 cn\n"
        printf "  请选择 [1-2]: "
    else
        printf "    1) International (default)  2) China Mainland\n"
        printf "  Choose [1-2]: "
    fi
    read -r INPUT_REGION </dev/tty 2>/dev/null || INPUT_REGION=""

    DEPLOY_REGION="intl"
    if [ "$INPUT_REGION" = "2" ]; then
        DEPLOY_REGION="cn"
        sed -i.bak "s|Region:.*|Region: cn   # intl / cn|" "$ENV_FILE"
        ok "$(msg "Region set to: China Mainland (cn)" "部署区域: 中国站 (cn)")"
    else
        ok "$(msg "Region set to: International (intl)" "部署区域: 国际站 (intl)")"
    fi
    echo ""

    # Set console URLs based on region
    if [ "$DEPLOY_REGION" = "cn" ]; then
        URL_CAM="https://console.cloud.tencent.com/cam/capi"
        URL_TRTC="https://console.cloud.tencent.com/trtc/app"
        URL_LLM="https://cloud.tencent.com/document/product/647/115413"
    else
        URL_CAM="https://console.intl.cloud.tencent.com/cam/capi"
        URL_TRTC="https://console.trtc.io/app"
        URL_LLM="https://trtc.io/document/68338?product=conversationalai"
    fi

    # --- 1/4 Cloud API Credentials / 腾讯云 API 密钥 ---
    printf "%b[1/4] $(msg "Tencent Cloud API Credentials" "腾讯云 API 密钥")%b\n" "$CYAN" "$NC"
    printf "  $(msg "Get from" "获取"): %b${URL_CAM}%b\n" "$BOLD" "$NC"
    printf "  SECRET_ID : " ; read -r INPUT_SID </dev/tty 2>/dev/null || INPUT_SID=""
    printf "  SECRET_KEY: " ; read -r INPUT_SKEY </dev/tty 2>/dev/null || INPUT_SKEY=""

    if [ -n "$INPUT_SID" ] && [ -n "$INPUT_SKEY" ]; then
        sed -i.bak "s|SECRET_ID:.*|SECRET_ID: ${INPUT_SID}|" "$ENV_FILE"
        sed -i.bak "s|SECRET_KEY:.*|SECRET_KEY: ${INPUT_SKEY}|" "$ENV_FILE"
        ok "$(msg "Cloud API credentials saved" "CloudAPI 密钥已填入")"
    else
        warn "$(msg "Skipped, please edit ${ENV_FILE} later" "已跳过，请稍后手动编辑 ${ENV_FILE}")"
    fi
    echo ""

    # --- 2/4 TRTC App / TRTC 应用 ---
    printf "%b[2/4] $(msg "TRTC App Credentials" "TRTC 应用凭据")%b\n" "$CYAN" "$NC"
    printf "  $(msg "Get from" "获取"): %b${URL_TRTC}%b\n" "$BOLD" "$NC"
    printf "  SDKAPPID: " ; read -r INPUT_APPID </dev/tty 2>/dev/null || INPUT_APPID=""
    printf "  SECRET  : " ; read -r INPUT_TSECRET </dev/tty 2>/dev/null || INPUT_TSECRET=""

    if [ -n "$INPUT_APPID" ] && [ -n "$INPUT_TSECRET" ]; then
        sed -i.bak "s|SDKAPPID:.*|SDKAPPID: ${INPUT_APPID}|" "$ENV_FILE"
        sed -i.bak "s|[REDACTED_SECRET] ${INPUT_TSECRET}|" "$ENV_FILE"
        ok "$(msg "TRTC credentials saved" "TRTC 凭据已填入")"
    else
        warn "$(msg "Skipped, please edit ${ENV_FILE} later" "已跳过，请稍后手动编辑 ${ENV_FILE}")"
    fi
    echo ""

    # --- 3/4 LLM API Key (TokenHub recommended) ---
    printf "%b[3/4] LLM API Key%b\n" "$CYAN" "$NC"
    if [ "$_IS_ZH" -eq 1 ]; then
        printf "  推荐使用 TokenHub（腾讯云 LLM 网关，开箱即用）\n"
    else
        printf "  Recommended: TokenHub (Tencent Cloud LLM gateway, ready to use)\n"
    fi
    if [ "$DEPLOY_REGION" = "cn" ]; then
        TOKENHUB_URL="https://tokenhub.tencentmaas.com/v1/chat/completions"
        printf "  $(msg "TokenHub Console" "TokenHub 控制台"): %bhttps://console.cloud.tencent.com/tokenhub%b\n" "$BOLD" "$NC"
    else
        TOKENHUB_URL="https://tokenhub-intl.tencentcloudmaas.com/v1/chat/completions"
        printf "  $(msg "TokenHub Console" "TokenHub 控制台"): %bhttps://console.intl.cloud.tencent.com/tokenhub%b\n" "$BOLD" "$NC"
    fi
    printf "  $(msg "Other LLM guide" "其他 LLM 配置指南"): %b${URL_LLM}%b\n" "$BOLD" "$NC"
    printf "  API Key: " ; read -r INPUT_LLM </dev/tty 2>/dev/null || INPUT_LLM=""

    if [ -n "$INPUT_LLM" ]; then
        sed -i.bak "s|[REDACTED_SECRET] ${INPUT_LLM}|" "$ENV_FILE"
        # 默认填入 TokenHub APIUrl 和推荐模型
        sed -i.bak "s|APIUrl:.*|APIUrl: ${TOKENHUB_URL}|" "$ENV_FILE"
        sed -i.bak "s|Model:.*|Model: deepseek-v4-flash|" "$ENV_FILE"
        ok "$(msg "LLM configured (TokenHub + deepseek-v4-flash)" "LLM 已配置（TokenHub + deepseek-v4-flash）")"
    else
        warn "$(msg "Skipped, please edit ${ENV_FILE} later" "已跳过，请稍后手动编辑 ${ENV_FILE}")"
    fi
    echo ""

    # --- 4/4 AI Customer Service Role / AI 客服角色配置 ---
    printf "%b[4/4] $(msg "AI Customer Service Role (optional)" "AI 客服角色配置（可选）")%b\n" "$CYAN" "$NC"
    if [ "$_IS_ZH" -eq 1 ]; then
        echo "  定制 AI 客服的业务角色，按 Enter 使用默认电商客服。"
        echo ""
        echo "  你的商城主营什么品类？"
        printf "    1) 综合电商（默认）  2) 数码产品  3) 服装鞋帽  4) 食品生鲜  5) 家居百货\n"
        printf "  请选择 [1-5]: " ; read -r INPUT_CAT </dev/tty 2>/dev/null || INPUT_CAT=""
        echo ""
        echo "  客服语气风格？"
        printf "    1) 亲切自然（默认）  2) 专业严谨  3) 活泼可爱\n"
        printf "  请选择 [1-3]: " ; read -r INPUT_TONE </dev/tty 2>/dev/null || INPUT_TONE=""
    else
        echo "  Customize AI role, press Enter for default e-commerce assistant."
        echo ""
        echo "  What product category does your store specialize in?"
        printf "    1) General e-commerce (default)  2) Electronics  3) Clothing  4) Food & Fresh  5) Home & Living\n"
        printf "  Choose [1-5]: " ; read -r INPUT_CAT </dev/tty 2>/dev/null || INPUT_CAT=""
        echo ""
        echo "  Customer service tone?"
        printf "    1) Warm & natural (default)  2) Professional  3) Lively & cheerful\n"
        printf "  Choose [1-3]: " ; read -r INPUT_TONE </dev/tty 2>/dev/null || INPUT_TONE=""
    fi

    # Build SystemPrompt based on choices
    case "$INPUT_CAT" in
        2) CAT_TEXT="数码产品" ; CAT_DUTY="解答电子产品参数对比、兼容性问题、保修政策、使用教程" ;;
        3) CAT_TEXT="服装鞋帽" ; CAT_DUTY="解答尺码推荐、面料材质、搭配建议、洗涤保养、退换尺码" ;;
        4) CAT_TEXT="食品生鲜" ; CAT_DUTY="解答保质期、储存方式、配送时效、食材产地、过敏原信息" ;;
        5) CAT_TEXT="家居百货" ; CAT_DUTY="解答商品尺寸规格、安装方式、材质说明、配送安装服务" ;;
        *) CAT_TEXT="" ; CAT_DUTY="" ;;
    esac

    case "$INPUT_TONE" in
        2) TONE_TEXT="语气专业严谨，用词准确规范" ;;
        3) TONE_TEXT="语气活泼可爱，适当使用轻松的表达方式" ;;
        *) TONE_TEXT="" ;;
    esac

    if [ -n "$CAT_TEXT" ] || [ -n "$TONE_TEXT" ]; then
        "$PY_CMD" -c "
import sys
cat_text = sys.argv[1]
cat_duty = sys.argv[2]
tone_text = sys.argv[3]

with open('env.yaml', 'r') as f:
    content = f.read()

# ---- Chinese ----
zh_insert = ''
if cat_text:
    zh_insert += '\n    本商城主营' + cat_text + '。你需要特别擅长：' + cat_duty + '。'
if tone_text:
    zh_insert += '\n    ' + tone_text + '。'
if zh_insert:
    content = content.replace('请遵循以下原则：', zh_insert.strip() + '\n    请遵循以下原则：', 1)

# ---- Cantonese ----
yue_insert = ''
if cat_text:
    yue_cat = {'数码产品':'數碼產品','服装鞋帽':'服裝鞋帽','食品生鲜':'食品生鮮','家居百货':'家居百貨'}.get(cat_text, cat_text)
    yue_duty = {'数码产品':'解答電子產品參數對比、兼容性問題、保修政策、使用教程','服装鞋帽':'解答尺碼推薦、面料材質、搭配建議、洗滌保養、退換尺碼','食品生鲜':'解答保質期、儲存方式、配送時效、食材產地、過敏原信息','家居百货':'解答商品尺寸規格、安裝方式、材質說明、配送安裝服務'}.get(cat_text, cat_duty)
    yue_insert += '\n    本商城主營' + yue_cat + '。你需要特別擅長：' + yue_duty + '。'
if tone_text:
    yue_tone = {'语气专业严谨，用词准确规范':'語氣專業嚴謹，用詞準確規範','语气活泼可爱，适当使用轻松的表达方式':'語氣活潑可愛，適當使用輕鬆嘅表達方式'}.get(tone_text, tone_text)
    yue_insert += '\n    ' + yue_tone + '。'
if yue_insert:
    content = content.replace('請遵循以下原則：', yue_insert.strip() + '\n    請遵循以下原則：', 1)

# ---- English ----
en_insert = ''
if cat_text:
    en_cat = {'数码产品':'digital and electronics products','服装鞋帽':'clothing and footwear','食品生鲜':'food and fresh produce','家居百货':'home and household goods'}.get(cat_text, cat_text)
    en_duty = {'数码产品':'answering questions about product specs, compatibility, warranty policies, and usage tutorials','服装鞋帽':'answering questions about sizing, fabric materials, styling suggestions, care instructions, and size exchanges','食品生鲜':'answering questions about shelf life, storage methods, delivery times, product origins, and allergen information','家居百货':'answering questions about product dimensions, installation, materials, and delivery services'}.get(cat_text, cat_duty)
    en_insert += '\n    This store specializes in ' + en_cat + '. You should be especially good at ' + en_duty + '.'
if tone_text:
    en_tone = {'语气专业严谨，用词准确规范':'Use a professional and precise tone with accurate wording','语气活泼可爱，适当使用轻松的表达方式':'Use a lively and cheerful tone with casual expressions'}.get(tone_text, '')
    if en_tone:
        en_insert += '\n    ' + en_tone + '.'
if en_insert:
    content = content.replace('Please follow these principles:', en_insert.strip() + '\n    Please follow these principles:', 1)

with open('env.yaml', 'w') as f:
    f.write(content)
" "$CAT_TEXT" "$CAT_DUTY" "$TONE_TEXT" 2>/dev/null && ok "$(msg "AI role customized - trilingual sync (${CAT_TEXT:-default}${TONE_TEXT:+ / }${TONE_TEXT:-})" "AI 客服角色已定制 - 三语同步（${CAT_TEXT:-默认}${TONE_TEXT:+ / }${TONE_TEXT:-}）")" || warn "$(msg "Customization failed, please edit SystemPrompt manually" "定制写入失败，请手动编辑 SystemPrompt")"

        # Generate mock orders matching the selected category
        if [ -n "$CAT_TEXT" ] && [ "$CAT_TEXT" != "" ]; then
            "$PY_CMD" -c "
import json, sys
cat = sys.argv[1]
ORDERS = {
    '数码产品': [
        {'id':'CS20260510001','name':{'zh':'无线降噪耳机 Pro','yue':'無線降噪耳機 Pro','en':'Wireless ANC Headphones Pro'},'price':{'zh':'¥699','yue':'HK\$759','en':'\$96'},'status':'shipped','date':'2026-05-10','emoji':'🎧'},
        {'id':'CS20260508002','name':{'zh':'轻薄笔记本电脑 14寸','yue':'輕薄筆記本電腦 14吋','en':'Ultra-slim Laptop 14\"'},'price':{'zh':'¥5,299','yue':'HK\$5,749','en':'\$729'},'status':'delivered','date':'2026-05-08','emoji':'💻'},
        {'id':'CS20260507003','name':{'zh':'智能手表 S9','yue':'智能手錶 S9','en':'Smart Watch S9'},'price':{'zh':'¥1,299','yue':'HK\$1,409','en':'\$179'},'status':'pending','date':'2026-05-07','emoji':'⌚'},
        {'id':'CS20260505004','name':{'zh':'机械键盘 RGB','yue':'機械鍵盤 RGB','en':'Mechanical Keyboard RGB'},'price':{'zh':'¥499','yue':'HK\$541','en':'\$69'},'status':'refunding','date':'2026-05-05','emoji':'⌨️'},
        {'id':'CS20260501005','name':{'zh':'蓝牙音箱 mini','yue':'藍牙音箱 mini','en':'Bluetooth Speaker Mini'},'price':{'zh':'¥199','yue':'HK\$216','en':'\$28'},'status':'delivered','date':'2026-05-01','emoji':'🔊'},
    ],
    '服装鞋帽': [
        {'id':'CS20260510001','name':{'zh':'轻奢羊绒大衣','yue':'輕奢羊絨大衣','en':'Luxury Cashmere Coat'},'price':{'zh':'¥2,399','yue':'HK\$2,603','en':'\$329'},'status':'shipped','date':'2026-05-10','emoji':'🧥'},
        {'id':'CS20260508002','name':{'zh':'运动跑鞋 Air Max','yue':'運動跑鞋 Air Max','en':'Running Shoes Air Max'},'price':{'zh':'¥899','yue':'HK\$976','en':'\$124'},'status':'delivered','date':'2026-05-08','emoji':'👟'},
        {'id':'CS20260507003','name':{'zh':'真丝连衣裙','yue':'真絲連衣裙','en':'Silk Dress'},'price':{'zh':'¥599','yue':'HK\$650','en':'\$82'},'status':'pending','date':'2026-05-07','emoji':'👗'},
        {'id':'CS20260505004','name':{'zh':'男士休闲西裤','yue':'男士休閒西褲','en':'Men Casual Trousers'},'price':{'zh':'¥349','yue':'HK\$379','en':'\$48'},'status':'refunding','date':'2026-05-05','emoji':'👖'},
        {'id':'CS20260501005','name':{'zh':'纯棉T恤 3件装','yue':'純棉T恤 3件裝','en':'Cotton T-shirt 3-Pack'},'price':{'zh':'¥129','yue':'HK\$140','en':'\$18'},'status':'delivered','date':'2026-05-01','emoji':'👕'},
    ],
    '食品生鲜': [
        {'id':'CS20260510001','name':{'zh':'有机草莓 2斤装','yue':'有機草莓 2斤裝','en':'Organic Strawberries 1kg'},'price':{'zh':'¥59','yue':'HK\$64','en':'\$8'},'status':'shipped','date':'2026-05-10','emoji':'🍓'},
        {'id':'CS20260508002','name':{'zh':'澳洲和牛 M7 眼肉','yue':'澳洲和牛 M7 眼肉','en':'Wagyu Ribeye M7'},'price':{'zh':'¥399','yue':'HK\$433','en':'\$55'},'status':'delivered','date':'2026-05-08','emoji':'🥩'},
        {'id':'CS20260507003','name':{'zh':'智利车厘子 2kg','yue':'智利車厘子 2kg','en':'Chilean Cherries 2kg'},'price':{'zh':'¥168','yue':'HK\$182','en':'\$23'},'status':'pending','date':'2026-05-07','emoji':'🍒'},
        {'id':'CS20260505004','name':{'zh':'挪威三文鱼刺身','yue':'挪威三文魚刺身','en':'Norwegian Salmon Sashimi'},'price':{'zh':'¥129','yue':'HK\$140','en':'\$18'},'status':'refunding','date':'2026-05-05','emoji':'🍣'},
        {'id':'CS20260501005','name':{'zh':'进口全脂牛奶 12盒','yue':'進口全脂牛奶 12盒','en':'Whole Milk 12-Pack'},'price':{'zh':'¥89','yue':'HK\$97','en':'\$12'},'status':'delivered','date':'2026-05-01','emoji':'🥛'},
    ],
    '家居百货': [
        {'id':'CS20260510001','name':{'zh':'智能扫地机器人','yue':'智能掃地機械人','en':'Robot Vacuum Cleaner'},'price':{'zh':'¥1,899','yue':'HK\$2,061','en':'\$261'},'status':'shipped','date':'2026-05-10','emoji':'🤖'},
        {'id':'CS20260508002','name':{'zh':'乳胶记忆枕头','yue':'乳膠記憶枕頭','en':'Memory Foam Pillow'},'price':{'zh':'¥299','yue':'HK\$325','en':'\$41'},'status':'delivered','date':'2026-05-08','emoji':'🛏️'},
        {'id':'CS20260507003','name':{'zh':'北欧风落地灯','yue':'北歐風落地燈','en':'Nordic Floor Lamp'},'price':{'zh':'¥459','yue':'HK\$498','en':'\$63'},'status':'pending','date':'2026-05-07','emoji':'💡'},
        {'id':'CS20260505004','name':{'zh':'不锈钢保温杯 500ml','yue':'不銹鋼保溫杯 500ml','en':'Insulated Bottle 500ml'},'price':{'zh':'¥89','yue':'HK\$97','en':'\$12'},'status':'refunding','date':'2026-05-05','emoji':'🫗'},
        {'id':'CS20260501005','name':{'zh':'竹纤维浴巾套装','yue':'竹纖維浴巾套裝','en':'Bamboo Towel Set'},'price':{'zh':'¥149','yue':'HK\$162','en':'\$20'},'status':'delivered','date':'2026-05-01','emoji':'🛁'},
    ],
}
if cat in ORDERS:
    with open('static/mock-orders.json', 'w', encoding='utf-8') as f:
        json.dump(ORDERS[cat], f, ensure_ascii=False, indent=4)
" "$CAT_TEXT" 2>/dev/null && ok "$(msg "Mock orders updated for: ${CAT_TEXT}" "订单数据已更新为: ${CAT_TEXT}")" || true
        fi
    else
        ok "$(msg "Using default e-commerce assistant role" "使用默认电商客服角色")"
    fi

    # Clean up sed backup files
    rm -f "${ENV_FILE}.bak"

    echo ""
    # Check for unfilled credential placeholders
    if grep -q "your-secret-id\|your-secret-key\|your-trtc-secret\|your-llm-api-key" "$ENV_FILE" 2>/dev/null; then
        warn "$(msg "Some credentials are not filled in" "检测到部分密钥未填写")"
        printf "  $(msg "Please edit" "请编辑") %b%s%b $(msg "and re-run" "补全后重新运行")\n" "$BOLD" "$ENV_FILE" "$NC"
        exit 0
    fi
    ok "$(msg "All credentials configured!" "所有密钥已配置完成！")"
    # Restrict file permissions (owner-only read/write)
    chmod 600 "$ENV_FILE" 2>/dev/null || true
    echo ""
fi
ok "$(msg "${ENV_FILE} exists" "${ENV_FILE} 已存在")"

# ---------------- Step 3: Virtual Environment / 虚拟环境 ----------------
if [ "$REBUILD" -eq 1 ] && [ -d "$VENV_DIR" ]; then
    warn "$(msg "Force rebuild: removing existing $VENV_DIR..." "强制重建：删除现有 $VENV_DIR...")"
    rm -rf "$VENV_DIR"
fi

if [ ! -d "$VENV_DIR" ]; then
    log "$(msg "Creating virtual environment $VENV_DIR..." "创建虚拟环境 $VENV_DIR...")"
    "$PY_CMD" -m venv "$VENV_DIR" 2>/dev/null || {
        err "$(msg "venv creation failed." "venv 创建失败。")"
        msg "  Linux users may need: sudo apt install python3-venv" "  Linux 用户可能需要先安装: sudo apt install python3-venv"
        exit 1
    }
    ok "$(msg "Virtual environment created" "虚拟环境创建完成")"
    NEED_INSTALL=1
else
    ok "$(msg "Virtual environment exists" "虚拟环境已存在")"
    NEED_INSTALL=0
fi

# Activate venv
# shellcheck disable=SC1091
source "$VENV_DIR/bin/activate"
VENV_PY="$VENV_DIR/bin/python"
VENV_PIP="$VENV_DIR/bin/pip"

# ---------------- Step 4: Check Dependencies / 检测依赖完整性 ----------------
if [ "$NEED_INSTALL" -eq 0 ]; then
    if ! "$VENV_PY" -c "import flask, envyaml, loguru, tencentcloud, TLSSigAPIv2" 2>/dev/null; then
        warn "$(msg "Missing dependencies detected, preparing to install..." "检测到依赖缺失，准备安装...")"
        NEED_INSTALL=1
    fi
fi

if [ "$NEED_INSTALL" -eq 1 ]; then
    log "$(msg "Installing dependencies..." "安装依赖...")"

    # Upgrade pip silently
    "$VENV_PIP" install --upgrade pip >/dev/null 2>&1 || true

    # Default: official PyPI; fallback: Tsinghua mirror (for users in mainland China)
    if "$VENV_PIP" install -r "$REQUIREMENTS" --timeout 30 2>/dev/null; then
        ok "$(msg "Dependencies installed (via PyPI)" "依赖安装完成（via 官方源）")"
    else
        warn "$(msg "PyPI failed, retrying with Tsinghua mirror..." "官方源失败，切换到清华镜像重试...")"
        MIRROR="https://pypi.tuna.tsinghua.edu.cn/simple"
        "$VENV_PIP" install -r "$REQUIREMENTS" -i "$MIRROR" --timeout 15 || die "$(msg "Dependency installation failed" "依赖安装失败")"
        ok "$(msg "Dependencies installed (via Tsinghua mirror)" "依赖安装完成（via 清华镜像）")"
    fi
else
    ok "$(msg "Dependencies ready" "依赖已就绪")"
fi

# ---------------- Step 5: Port Conflict / 端口冲突检测 ----------------
log "$(msg "Checking if port ${PORT} is in use..." "检查端口 ${PORT} 是否被占用...")"

EXISTING_PID=""
if command -v lsof >/dev/null 2>&1; then
    EXISTING_PID=$(lsof -ti :"$PORT" -sTCP:LISTEN 2>/dev/null || true)
elif command -v ss >/dev/null 2>&1; then
    EXISTING_PID=$(ss -tlnp "sport = :$PORT" 2>/dev/null | grep -oP 'pid=\K[0-9]+' | head -1 || true)
elif command -v netstat >/dev/null 2>&1; then
    EXISTING_PID=$(netstat -tlnp 2>/dev/null | grep ":$PORT " | grep -oP '[0-9]+(?=/)' | head -1 || true)
fi

if [ -n "$EXISTING_PID" ]; then
    EXISTING_CMD=$(ps -p "$EXISTING_PID" -o comm= 2>/dev/null || echo "unknown")
    EXISTING_ARGS=$(ps -p "$EXISTING_PID" -o args= 2>/dev/null || echo "")
    warn "$(msg "Port ${PORT} is in use (PID: ${EXISTING_PID}, process: ${EXISTING_CMD})" "端口 ${PORT} 已被占用 (PID: ${EXISTING_PID}, 进程: ${EXISTING_CMD})")"
    if [ -n "$EXISTING_ARGS" ]; then
        printf "  $(msg "Command" "命令"): %s\n" "$EXISTING_ARGS"
    fi
    printf "  $(msg "Kill this process and continue? [y/N]" "是否终止该进程并继续启动？[y/N]") "
    read -r REPLY </dev/tty 2>/dev/null || REPLY="n"
    REPLY=${REPLY:-n}
    case "$REPLY" in
        [Yy]*)
            kill "$EXISTING_PID" 2>/dev/null || true
            sleep 1
            if kill -0 "$EXISTING_PID" 2>/dev/null; then
                kill -9 "$EXISTING_PID" 2>/dev/null || true
                sleep 1
            fi
            ok "$(msg "Killed process occupying port (PID: ${EXISTING_PID})" "已终止占用端口的进程 (PID: ${EXISTING_PID})")"
            ;;
        *)
            die "$(msg "Port ${PORT} is occupied, please free it and retry" "端口 ${PORT} 被占用，请手动释放后重试")"
            ;;
    esac
fi

# ---------------- Step 6: Auto-detect public network / 公网环境检测 ----------------
# Detect public IP via external services (ifconfig.me, api.ipify.org, icanhazip.com)
# to determine if HTTPS should be auto-enabled for WebRTC microphone access.
log "$(msg "Detecting public network (querying ifconfig.me)..." "检测公网环境（查询 ifconfig.me）...")"
PUBLIC_IP=""
get_public_ip() {
    for url in "https://ifconfig.me" "https://api.ipify.org" "https://icanhazip.com"; do
        local ip
        ip=$(curl -s --max-time 3 "$url" 2>/dev/null | tr -d '[:space:]')
        if echo "$ip" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then
            echo "$ip"
            return 0
        fi
    done
    return 1
}

# Get local IP for reference
LOCAL_IP=$(hostname -I 2>/dev/null | awk '{print $1}' || ipconfig getifaddr en0 2>/dev/null || echo "127.0.0.1")

# Check if --https was explicitly passed
EXPLICIT_HTTPS=0
USE_HTTPS=0
for arg in "$@"; do
    [ "$arg" = "--https" ] && { USE_HTTPS=1; EXPLICIT_HTTPS=1; }
done

# Auto-detect: if not localhost and no explicit --https/--no-https, try to detect public IP and auto-enable HTTPS
AUTO_HTTPS=0
if [ "$EXPLICIT_HTTPS" -eq 0 ]; then
    PUBLIC_IP=$(get_public_ip 2>/dev/null || true)
    if [ -n "$PUBLIC_IP" ] && [ "$PUBLIC_IP" != "127.0.0.1" ]; then
        log "$(msg "Public IP detected: $PUBLIC_IP, auto-enabling HTTPS" "检测到公网 IP: $PUBLIC_IP，自动启用 HTTPS")"
        USE_HTTPS=1
        AUTO_HTTPS=1
    fi
fi

# ---------------- Step 7: HTTPS Self-signed Cert / HTTPS 自签证书 ----------------
CERT_DIR="certs"
CERT_FILE="$CERT_DIR/cert.pem"
KEY_FILE="$CERT_DIR/key.pem"

if [ "$USE_HTTPS" -eq 1 ]; then
    log "$(msg "Preparing HTTPS certificate..." "准备 HTTPS 证书...")"
    if [ -f "$CERT_FILE" ] && [ -f "$KEY_FILE" ]; then
        ok "$(msg "Certificate found: $CERT_FILE" "已有证书: $CERT_FILE")"
    else
        if ! command -v openssl >/dev/null 2>&1; then
            if [ "$AUTO_HTTPS" -eq 1 ]; then
                warn "$(msg "openssl not found, falling back to HTTP" "openssl 未安装，回退到 HTTP 模式")"
                USE_HTTPS=0
            else
                die "$(msg "openssl not installed, cannot generate self-signed cert" "openssl 未安装，无法生成自签证书")"
            fi
        fi
        if [ "$USE_HTTPS" -eq 1 ]; then
            mkdir -p "$CERT_DIR"
            log "$(msg "Generating self-signed certificate (valid 365 days)..." "生成自签证书（有效期 365 天）...")"
            openssl req -x509 -newkey rsa:2048 -nodes \
                -keyout "$KEY_FILE" -out "$CERT_FILE" \
                -days 365 -subj "/CN=localhost" \
                -addext "subjectAltName=DNS:localhost,IP:0.0.0.0" \
                2>/dev/null
            ok "$(msg "Certificate generated: $CERT_DIR/" "证书已生成: $CERT_DIR/")"
            echo ""
            warn "$(msg "Self-signed certificate: browser will show a security warning on first visit." "这是自签证书，浏览器首次访问会提示\"不安全\"")"
            msg "  Click \"Advanced\" → \"Proceed\" to continue." "  解决方法: 点击\"高级\" → \"继续前往\"即可正常使用"
            echo ""
        fi
    fi
fi

# ---------------- Step 8: Launch / 启动 ----------------
echo ""
printf "%b════════════════════════════════════════════════%b\n" "$BOLD" "$NC"
printf "%b🚀 $(msg "Launching TRTC AI Customer Service" "启动 TRTC AI 智能客服")%b\n" "$GREEN" "$NC"
printf "%b════════════════════════════════════════════════%b\n" "$BOLD" "$NC"
PROTO="http"
[ "$USE_HTTPS" -eq 1 ] && PROTO="https"
if [ -n "$PUBLIC_IP" ] && [ "$PUBLIC_IP" != "127.0.0.1" ]; then
    printf "   $(msg "Public" "公网"): %b%b${PROTO}://${PUBLIC_IP}:%s%b\n" "$CYAN" "$BOLD" "$PORT" "$NC"
    printf "   $(msg "Local" "内网"):  %b${PROTO}://${LOCAL_IP}:%s%b\n" "$CYAN" "$PORT" "$NC"
    printf "   $(msg "Local" "本地"):  %b${PROTO}://localhost:%s%b\n" "$CYAN" "$PORT" "$NC"
else
    printf "   $(msg "Visit" "访问"): %b%b${PROTO}://localhost:%s%b\n" "$CYAN" "$BOLD" "$PORT" "$NC"
fi
printf "   $(msg "Stop" "停止"): %bCtrl+C%b\n" "$YELLOW" "$NC"
printf "%b════════════════════════════════════════════════%b\n" "$BOLD" "$NC"
echo ""

if [ "$USE_HTTPS" -eq 1 ]; then
    exec "$VENV_PY" app.py --https
else
    exec "$VENV_PY" app.py
fi


path: assets/static/app.js
content: // ===================================================================
// AI 智能客服 - 前端逻辑（IM 风格 + 语音/文字双模 + 手动评分）
// ===================================================================
'use strict';

const STATE = {
    userId: '',
    roomId: 0,
    sdkAppId: 0,
    userSig: '',
    robotUserId: '',
    robotUserSig: '',
    avatarUserId: '',
    avatarUserSig: '',
    avatarAvailable: false,
    useAvatar: false,
    gender: 'female',
    callState: 'idle',       // idle | connecting | active | ending
    taskId: null,
    startTime: null,
    muted: false,
    localMuted: false,
    aiSpeaking: false,
    endKeywords: { zh: [], yue: [], en: [] },
    farewellMessage: { zh: '', yue: '', en: '' },
    transferKeywords: { zh: [], yue: [], en: [] },
    transferMessage: { zh: '', yue: '', en: '' },

    // 消息管理
    messages: [],             // [{id, role: 'user'|'ai', text, time, streaming}]
    typingMsgId: null,        // 当前 AI 正在输入的消息 ID
    addedUserTexts: new Set(),
    addedAIRounds: new Set(),
    aiRoundText: {},          // roundid -> accumulated text
    aiRoundLast: {},          // roundid -> last chunk (for dedup)
    aiRoundMsgId: {},         // roundid -> message bubble id
};

const USER_CFG = {
    lang: I18N.getLang(),
    mode: 'voice',            // voice | avatar
    inputMode: 'voice',       // voice | text
    interruptMode: 0,
    interruptSpeechDuration: 600,
    vadLevel: 3,
    vadSilenceTime: 600,
    turnDetectionMode: 3,
};

const $ = (id) => document.getElementById(id);

// Avatar CDN fallback (when local avatars are missing, e.g. ClawHub install without .png)
const AVATAR_CDN = 'https://raw.githubusercontent.com/jerryang-cool/trtc-ai-customer-service-skill/main/assets/static/avatars';

// ========== DOM refs ==========
const els = {
    sidebar: $('sidebar'),
    sidebarOverlay: $('sidebar-overlay'),
    btnToggleSidebar: $('btn-toggle-sidebar'),
    toggleSidebarText: $('toggle-sidebar-text'),
    langSwitchLabel: $('lang-switch-label'),
    langFlag: $('lang-flag'),
    langDropdown: $('lang-dropdown'),
    langToggle: $('lang-toggle'),
    langMenu: $('lang-menu'),
    modeSwitch: $('mode-switch'),
    modeAvatarBtn: $('mode-avatar-btn'),
    turnSwitch: $('turn-switch'),
    interruptSwitch: $('interrupt-switch'),
    advToggle: $('adv-toggle'),
    advContainer: $('adv-container'),
    sliderVadSilence: $('slider-vad-silence'),
    valVadSilence: $('val-vad-silence'),
    sliderVadLevel: $('slider-vad-level'),
    valVadLevel: $('val-vad-level'),
    sliderInterruptDuration: $('slider-interrupt-duration'),
    valInterruptDuration: $('val-interrupt-duration'),
    groupInterruptDuration: $('group-interrupt-duration'),

    agentSelectArea: $('agent-select-area'),
    agentCards: $('agent-cards'),
    avatarImgFemale: $('avatar-img-female'),
    avatarImgMale: $('avatar-img-male'),
    btnStart: $('btn-start'),

    chatArea: $('chat-area'),
    chatMessages: $('chat-messages'),
    avatarVideoLayer: $('avatar-video-layer'),
    avatarVideoContainer: $('avatar-video-container'),
    voicePanel: $('voice-panel'),
    textPanel: $('text-panel'),
    voiceStatus: $('voice-status'),
    micBtn: $('mic-btn'),
    btnInputModeSwitch: $('btn-input-mode-switch'),
    modeIconKeyboard: $('mode-icon-keyboard'),
    modeIconMic: $('mode-icon-mic'),
    textInput: $('text-input'),
    btnEnd: $('btn-end'),
    btnEnd: $('btn-end'),

    ratingOverlay: $('rating-overlay'),
    ratingMain: $('rating-main'),
    ratingThanks: $('rating-thanks'),
    ratingDimensions: $('rating-dimensions'),
    btnRatingSkip: $('btn-rating-skip'),
    btnRatingSubmit: $('btn-rating-submit'),
    btnRatingClose: $('btn-rating-close'),
    btnRatingRestart: $('btn-rating-restart'),
    ratingThanksDesc: $('rating-thanks-desc'),
};

let trtcClient = null;

// ========== Utils ==========
function randomId(n = 6) {
    const chars = 'abcdefghijklmnop';
    let s = '';
    for (let i = 0; i < n; i++) s += chars[Math.floor(Math.random() * chars.length)];
    return s;
}
function uuid() {
    return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
        const r = Math.random() * 16 | 0;
        return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
    });
}
function escapeHtml(s) {
    return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
async function postAction(action, payload) {
    const res = await fetch('/action', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'Action': action },
        body: JSON.stringify(payload || {}),
    });
    return res.json();
}

// ========== Mock 订单数据（从 mock-orders.json 加载，方便用户自定义商品） ==========
const _oi = (emoji) => `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80"><rect width="80" height="80" rx="8" fill="%23F3F4F6"/><text x="50%" y="54%" text-anchor="middle" dominant-baseline="middle" font-size="36">${emoji}</text></svg>`)}`;

// 默认订单（如果 mock-orders.json 加载失败则使用）
let MOCK_ORDERS = [
    { id: 'CS20260510001', name: { zh: '无线降噪耳机 Pro', yue: '無線降噪耳機 Pro', en: 'Wireless ANC Headphones Pro' }, price: { zh: '¥699', yue: 'HK$759', en: '$96' }, status: 'shipped', date: '2026-05-10', emoji: '🎧' },
    { id: 'CS20260508002', name: { zh: '轻薄笔记本电脑 14寸', yue: '輕薄筆記本電腦 14吋', en: 'Ultra-slim Laptop 14"' }, price: { zh: '¥5,299', yue: 'HK$5,749', en: '$729' }, status: 'delivered', date: '2026-05-08', emoji: '💻' },
    { id: 'CS20260507003', name: { zh: '智能手表 S9', yue: '智能手錶 S9', en: 'Smart Watch S9' }, price: { zh: '¥1,299', yue: 'HK$1,409', en: '$179' }, status: 'pending', date: '2026-05-07', emoji: '⌚' },
    { id: 'CS20260505004', name: { zh: '真皮双肩包', yue: '真皮雙肩包', en: 'Genuine Leather Backpack' }, price: { zh: '¥399', yue: 'HK$433', en: '$55' }, status: 'refunding', date: '2026-05-05', emoji: '🎒' },
    { id: 'CS20260501005', name: { zh: '空气炸锅 5L', yue: '空氣炸鍋 5L', en: 'Air Fryer 5L' }, price: { zh: '¥259', yue: 'HK$281', en: '$36' }, status: 'delivered', date: '2026-05-01', emoji: '🍳' },
];

// 尝试从 mock-orders.json 加载自定义订单数据
fetch('/static/mock-orders.json').then(r => r.ok ? r.json() : Promise.reject()).then(data => {
    if (Array.isArray(data) && data.length > 0) {
        MOCK_ORDERS = data.map(o => ({ ...o, img: o.img || _oi(o.emoji || '📦') }));
        if (STATE.callState === 'active') renderOrderList();
    }
}).catch(() => {});

// 确保所有订单都有 img 字段
MOCK_ORDERS = MOCK_ORDERS.map(o => ({ ...o, img: o.img || _oi(o.emoji || '📦') }));

// ========== 转人工关键词检测 ==========
function detectTransferKeyword(text) {
    if (!text) return null;
    const list = STATE.transferKeywords[USER_CFG.lang] || [];
    for (const kw of list) if (buildKeywordRegex(kw).test(text)) return kw;
    return null;
}

// ========== 关键词词边界检测 ==========
function buildKeywordRegex(keyword) {
    const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    if (/[\u4e00-\u9fa5]/.test(keyword)) {
        // 中文：两侧不能紧邻同类字符（中文 / 字母数字）
        return new RegExp(`(^|[^\\u4e00-\\u9fa5A-Za-z0-9])${escaped}(?=[^\\u4e00-\\u9fa5A-Za-z0-9]|$)`, 'i');
    }
    return new RegExp(`\\b${escaped}\\b`, 'i');
}
function detectEndKeyword(text) {
    if (!text) return null;
    const list = STATE.endKeywords[USER_CFG.lang] || [];
    for (const kw of list) if (buildKeywordRegex(kw).test(text)) return kw;
    return null;
}

// ========== 消息气泡渲染 ==========
function avatarFileName(lang, gender) {
    return `${lang === 'yue' ? 'zh' : lang}_${gender || 'female'}.png`;
}
function getAgentAvatarUrl() {
    return `/static/avatars/${avatarFileName(USER_CFG.lang, STATE.gender)}`;
}
function avatarOnError(img) {
    // Fallback to GitHub CDN if local avatar is missing
    const src = img.getAttribute('src') || '';
    if (!src.startsWith(AVATAR_CDN)) {
        const filename = src.split('/').pop();
        img.src = `${AVATAR_CDN}/${filename}`;
    }
}

function renderMessage(msg) {
    // 系统通知（居中灰色文字）
    if (msg.role === 'system') {
        return `<div class="message system" data-msg-id="${msg.id}"><span class="system-notice">${escapeHtml(msg.text)}</span></div>`;
    }

    const isUser = msg.role === 'user';
    const avatarEl = isUser
        ? `<div class="msg-avatar">我</div>`
        : `<div class="msg-avatar"><img src="${getAgentAvatarUrl()}" alt="agent" onerror="avatarOnError(this)"></div>`;

    // 订单卡片渲染
    if (msg.card) {
        const c = msg.card;
        const statusCls = 'order-status-' + c.status;
        return `
            <div class="message user" data-msg-id="${msg.id}">
                ${avatarEl}
                <div class="msg-card">
                    <img class="msg-card-img" src="${c.img}" alt="">
                    <div class="msg-card-body">
                        <div class="msg-card-name">${escapeHtml(c.localName)}</div>
                        <div class="msg-card-meta">${c.id}</div>
                        <div class="msg-card-bottom">
                            <span class="msg-card-price">${c.localPrice || c.price?.zh || ''}</span>
                            <span class="order-status ${statusCls}">${c.statusText}</span>
                        </div>
                    </div>
                </div>
            </div>
        `;
    }

    const bubbleCls = msg.streaming ? 'msg-bubble typing-bubble' : 'msg-bubble';
    const content = msg.streaming && !msg.text
        ? `<div class="dots"><span></span><span></span><span></span></div>`
        : escapeHtml(msg.text || '');
    // 打断按钮：AI 消息 + streaming + 手动打断模式 + active 状态
    const showInterrupt = !isUser
        && msg.streaming
        && USER_CFG.interruptMode === 1
        && STATE.callState === 'active';
    const interruptBtn = showInterrupt
        ? `<button class="btn-interrupt" onclick="sendInterrupt()">
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><rect x="9" y="9" width="6" height="6" rx="0.5" fill="currentColor" stroke="none"/></svg>
            <span data-i18n="btnInterrupt">${I18N.t('btnInterrupt')}</span>
          </button>`
        : '';
    const bubbleAndBtn = isUser
        ? `<div class="${bubbleCls}">${content}</div>`
        : `<div class="ai-col">
             <div class="${bubbleCls}">${content}</div>
             ${interruptBtn}
           </div>`;
    return `
        <div class="message ${isUser ? 'user' : 'ai'}" data-msg-id="${msg.id}">
            ${avatarEl}
            ${bubbleAndBtn}
        </div>
    `;
}

function renderAllMessages() {
    els.chatMessages.innerHTML = STATE.messages.map(renderMessage).join('');
    els.chatMessages.scrollTop = els.chatMessages.scrollHeight;
}

function addMessage(role, text, opts = {}) {
    const msg = {
        id: opts.id || uuid(),
        role,
        text,
        time: Date.now(),
        streaming: !!opts.streaming,
    };
    STATE.messages.push(msg);
    renderAllMessages();
    return msg.id;
}

function setSystemNotice(text, noticeId) {
    const id = noticeId || '_sys_notice';
    const existing = STATE.messages.find(m => m.id === id);
    if (existing) {
        existing.text = text;
        const el = els.chatMessages.querySelector(`[data-msg-id="${id}"] .system-notice`);
        if (el) { el.textContent = text; els.chatMessages.scrollTop = els.chatMessages.scrollHeight; return id; }
        renderAllMessages();
        return id;
    }
    const msg = { id, role: 'system', text, time: Date.now(), streaming: false };
    STATE.messages.push(msg);
    renderAllMessages();
    return id;
}

function removeSystemNotice(noticeId) {
    const id = noticeId || '_sys_notice';
    const idx = STATE.messages.findIndex(m => m.id === id);
    if (idx !== -1) { STATE.messages.splice(idx, 1); renderAllMessages(); }
}

function updateMessage(id, text, opts = {}) {
    const msg = STATE.messages.find(m => m.id === id);
    if (!msg) return;
    const streamingChanged = typeof opts.streaming !== 'undefined' && msg.streaming !== !!opts.streaming;
    msg.text = text;
    if (typeof opts.streaming !== 'undefined') msg.streaming = opts.streaming;
    // 只更新这一条的内容，避免整体重绘
    const el = els.chatMessages.querySelector(`[data-msg-id="${id}"]`);
    if (el) {
        const bubble = el.querySelector('.msg-bubble');
        if (bubble) {
            bubble.classList.toggle('typing-bubble', !!msg.streaming);
            if (msg.streaming && !text) {
                bubble.innerHTML = `<div class="dots"><span></span><span></span><span></span></div>`;
            } else {
                bubble.innerHTML = escapeHtml(text);
            }
        }
        els.chatMessages.scrollTop = els.chatMessages.scrollHeight;
        if (streamingChanged) updateInterruptBtn();
    } else {
        renderAllMessages();
    }
}

// ========== AI 状态 ==========
function updateInterruptBtn() {
    // 同步所有 AI 消息气泡下方的打断按钮显隐
    const shouldShow = (msg) => !!msg && msg.role === 'ai'
        && msg.streaming
        && USER_CFG.interruptMode === 1
        && STATE.callState === 'active';

    STATE.messages.forEach(msg => {
        if (msg.role !== 'ai' || msg.role === 'system') return;
        const el = els.chatMessages.querySelector(`[data-msg-id="${msg.id}"] .ai-col`);
        if (!el) return;
        const existing = el.querySelector('.btn-interrupt');
        if (shouldShow(msg)) {
            if (!existing) {
                const btnHtml = `<button class="btn-interrupt" onclick="sendInterrupt()">
                    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><rect x="9" y="9" width="6" height="6" rx="0.5" fill="currentColor" stroke="none"/></svg>
                    <span>${I18N.t('btnInterrupt')}</span>
                </button>`;
                el.insertAdjacentHTML('beforeend', btnHtml);
            }
        } else if (existing) {
            existing.remove();
        }
    });
}

const STATUS_MAP = {
    1: 'statusListening',
    2: 'statusThinking',
    3: 'statusSpeaking',
    4: 'statusInterrupted',
    5: 'statusFinished',
};
function setAIStatus(state) {
    const key = STATUS_MAP[state];
    if (!key) return;
    els.voiceStatus.textContent = I18N.t(key);

    // 追踪 AI 说话状态，控制打断按钮
    STATE.aiSpeaking = (state === 3);
    updateInterruptBtn();

    // 状态为"思考中"时给用户消息加一条 AI 打字中的占位气泡
    if (state === 2 && !STATE.typingMsgId) {
        STATE.typingMsgId = addMessage('ai', '', { streaming: true });
    }
    // 告别/转接流程中，state=5（已说完）即表示推送文本播报完成，立即触发后续流程
    // 与 REMOTE_USER_LEAVE 双保险：谁先到谁触发
    if (state === 5 && (STATE.callState === 'ending' || STATE.callState === 'transferring')) {
        if (STATE._onRobotLeave) {
            STATE._onRobotLeave();
            STATE._onRobotLeave = null;
        }
    }
}

// ========== TRTC 自定义消息处理 ==========
function handleMessage(data, eventUserId) {
    // type 10001: AI 状态
    if (data.type === 10001) {
        const state = data.payload?.state;
        if (state) setAIStatus(state);
        return;
    }
    // type 10000: 字幕
    if (data.type === 10000) {
        const text = data.payload?.text || '';
        const sender = data.sender || eventUserId || '';
        const end = data.payload?.end === true;
        const roundid = data.payload?.roundid || '';
        const isUser = (sender === STATE.userId);

        if (isUser) {
            // 用户语音 ASR 结果（仅在 end=true 才落气泡，避免累积乱码）
            if (end && text.trim() && !STATE.addedUserTexts.has(text)) {
                STATE.addedUserTexts.add(text);
                addMessage('user', text);
                // 转人工关键词检测（优先于结束关键词）
                const transferHit = detectTransferKeyword(text);
                if (transferHit) {
                    console.log(`🔄 转人工关键词命中: "${transferHit}"`);
                    triggerTransfer();
                    return;
                }
                // 结束关键词检测
                const hit = detectEndKeyword(text);
                if (hit) {
                    console.log(`🛑 结束关键词命中: "${hit}"`);
                    farewellAndEnd();
                }
            }
        } else {
            // AI 字幕（增量/累积自适应）
            if (text.trim() && roundid) {
                const last = STATE.aiRoundLast[roundid] || '';
                const cur = STATE.aiRoundText[roundid] || '';
                const isAccumulative = last && text.startsWith(last);
                STATE.aiRoundText[roundid] = isAccumulative ? text : cur + text;
                STATE.aiRoundLast[roundid] = text;

                // 确保有一个气泡对应该 round
                let msgId = STATE.aiRoundMsgId[roundid];
                if (!msgId) {
                    // 复用"思考中"占位，或新建
                    if (STATE.typingMsgId) {
                        msgId = STATE.typingMsgId;
                        STATE.typingMsgId = null;
                    } else {
                        msgId = addMessage('ai', '', { streaming: true });
                    }
                    STATE.aiRoundMsgId[roundid] = msgId;
                }
                updateMessage(msgId, STATE.aiRoundText[roundid], { streaming: !end });
            }
            if (end && roundid) {
                const msgId = STATE.aiRoundMsgId[roundid];
                if (msgId) updateMessage(msgId, STATE.aiRoundText[roundid] || text, { streaming: false });
                STATE.addedAIRounds.add(roundid);
                delete STATE.aiRoundText[roundid];
                delete STATE.aiRoundLast[roundid];
                delete STATE.aiRoundMsgId[roundid];
            }
        }
    }
}

// ========== 启动对话 ==========
async function startCall() {
    if (typeof TRTC === 'undefined') {
        alert(I18N.t('tipSdkLoadFailed'));
        return;
    }
    if (STATE.callState !== 'idle') return;

    STATE.callState = 'connecting';
    els.btnStart.disabled = true;

    // 重置
    STATE.messages = [];
    STATE.addedUserTexts = new Set();
    STATE.addedAIRounds = new Set();
    STATE.aiRoundText = {};
    STATE.aiRoundLast = {};
    STATE.aiRoundMsgId = {};
    STATE.typingMsgId = null;
    STATE.muted = false;
    STATE.localMuted = false;
    renderAllMessages();

    // 重置麦克风/静音按钮 UI
    const btnMuteEl = $('btn-mute');
    if (btnMuteEl) { btnMuteEl.classList.remove('muted'); btnMuteEl.title = I18N.t('btnMute'); }
    const micBtnEl = $('mic-btn');
    if (micBtnEl) micBtnEl.classList.remove('muted');

    STATE.userId = randomId(6);
    STATE.roomId = Math.floor(Math.random() * 90000) + 10000;

    try {
        const joinRsp = await postAction('join', { userid: STATE.userId });
        if (joinRsp.Response?.Error) throw new Error(joinRsp.Response.Error.Message);

        STATE.sdkAppId = joinRsp.sdkappid;
        STATE.userSig = joinRsp.usersig;
        STATE.robotUserId = joinRsp.robot_userid;
        STATE.robotUserSig = joinRsp.robot_usersig;
        STATE.avatarUserId = joinRsp.avatar_userid || '';
        STATE.avatarUserSig = joinRsp.avatar_usersig || '';
        STATE.avatarAvailable = !!joinRsp.avatar_available;
        STATE.endKeywords = joinRsp.end_keywords || { zh: [], yue: [], en: [] };
        STATE.farewellMessage = joinRsp.farewell_message || { zh: '', yue: '', en: '' };
        STATE.transferKeywords = joinRsp.transfer_keywords || { zh: [], yue: [], en: [] };
        STATE.transferMessage = joinRsp.transfer_message || { zh: '', yue: '', en: '' };
        STATE.useAvatar = (USER_CFG.mode === 'avatar') && STATE.avatarAvailable;

        // UI 切换
        els.agentSelectArea.style.display = 'none';
        els.chatArea.classList.add('active');
        setAIStatus(1);  // listening

        // TRTC
        trtcClient = TRTC.create();
        trtcClient.on(TRTC.EVENT.CUSTOM_MESSAGE, (event) => {
            try {
                const text = new TextDecoder().decode(event.data);
                const data = JSON.parse(text);
                handleMessage(data, event.userId);
            } catch (e) { console.warn('parse msg failed', e); }
        });
        // 数字人模式：订阅远端视频流并渲染
        trtcClient.on(TRTC.EVENT.REMOTE_VIDEO_AVAILABLE, ({ userId, streamType }) => {
            if (STATE.useAvatar && userId === STATE.avatarUserId) {
                console.log('🎬 Avatar video available:', userId, streamType);
                trtcClient.startRemoteVideo({
                    userId,
                    streamType,
                    view: 'avatar-video-container',
                });
                // 激活 avatar 视频层和 UI 模式
                enterAvatarMode();
            }
        });
        // AI 机器人退房事件（StopAfterPlay 播报完成后任务自动结束，机器人退房）
        trtcClient.on(TRTC.EVENT.REMOTE_USER_LEAVE, ({ userId }) => {
            if (userId === STATE.robotUserId && STATE._onRobotLeave) {
                STATE._onRobotLeave();
                STATE._onRobotLeave = null;
            }
        });

        await trtcClient.enterRoom({
            roomId: STATE.roomId,
            scene: 'rtc',
            sdkAppId: STATE.sdkAppId,
            userId: STATE.userId,
            userSig: STATE.userSig,
        });

        // 仅麦克风（不启动摄像头）
        try {
            await trtcClient.startLocalAudio();
        } catch (e) {
            console.error('mic failed', e);
            alert(I18N.t('tipMicrophoneFailed'));
            await trtcClient.exitRoom();
            trtcClient.destroy();
            trtcClient = null;
            STATE.callState = 'idle';
            els.btnStart.disabled = false;
            els.chatArea.classList.remove('active');
            els.agentSelectArea.style.display = 'flex';
            return;
        }

        // 启动 AI
        const body = {
            RoomId: STATE.roomId,
            AgentConfig: {
                UserId: STATE.robotUserId,
                UserSig: STATE.robotUserSig,
                TargetUserId: STATE.userId,
                Lang: USER_CFG.lang,
            },
            UserConfig: {
                TurnDetectionMode: USER_CFG.turnDetectionMode,
                InterruptMode: USER_CFG.interruptMode,
                InterruptSpeechDuration: USER_CFG.interruptSpeechDuration,
                VadLevel: USER_CFG.vadLevel,
                VadSilenceTime: USER_CFG.vadSilenceTime,
                Gender: STATE.gender,
            },
            UseAvatar: STATE.useAvatar,
            AvatarConfig: {
                AvatarUserID: STATE.avatarUserId,
                AvatarUserSig: STATE.avatarUserSig,
            },
        };
        const startRsp = await postAction('StartAIConversation', body);
        if (startRsp.Response?.Error) throw new Error(startRsp.Response.Error.Message);
        STATE.taskId = startRsp.TaskId;
        if (!STATE.taskId) throw new Error('No TaskId returned');

        STATE.callState = 'active';
        STATE.startTime = Date.now();
        console.log('✅ AI started, TaskId=' + STATE.taskId);
    } catch (e) {
        console.error('startCall failed:', e);
        alert(I18N.t('tipAIStartFailed') + (e.message || e));
        try { if (trtcClient) { await trtcClient.exitRoom(); trtcClient.destroy(); trtcClient = null; } } catch {}
        STATE.callState = 'idle';
        els.btnStart.disabled = false;
        els.chatArea.classList.remove('active');
        els.agentSelectArea.style.display = 'flex';
    }
}

// ========== 转人工流程（模拟演示，实际生产中需对接真实排队/路由系统） ==========
async function triggerTransfer() {
    if (STATE.callState !== 'active') return;
    STATE.callState = 'transferring';

    const msg = STATE.transferMessage[USER_CFG.lang] || '正在转接人工客服...';
    // 不手动 addMessage，转接提示语由 ServerPushText 播报后通过字幕回调自动展示

    // 通过 ServerPushText 播报转接提示语，播报完自动结束 AI 任务
    // 等待 AI 机器人退房（StopAfterPlay=true → 播报完 → 任务结束 → 机器人退房）
    const robotLeavePromise = new Promise(resolve => {
        STATE._onRobotLeave = resolve;
        setTimeout(() => { STATE._onRobotLeave = null; resolve(); }, 30000); // 兜底超时 30s
    });
    try {
        await postAction('TransferAndStop', {
            TaskId: STATE.taskId,
            Lang: USER_CFG.lang,
            TransferText: msg,
        });
    } catch (e) { console.warn('TransferAndStop failed', e); }

    await robotLeavePromise;

    // 模拟排队进展（系统通知，同一行更新）
    const queueSteps = {
        zh: ['正在为您查找空闲客服...（演示模式）', '前方还有 5 位用户，请稍候（演示模式）', '前方还有 3 位用户，请耐心等待（演示模式）', '前方还有 1 位用户，即将为您接通（演示模式）', '正在接通中...（演示模式）'],
        yue: ['正在為您查找空閒客服...（演示模式）', '前方仲有 5 位用戶，請稍候（演示模式）', '前方仲有 3 位用戶，請耐心等待（演示模式）', '前方仲有 1 位用戶，即將為您接通（演示模式）', '正在接通中...（演示模式）'],
        en: ['Finding an available agent... (demo mode)', '5 people ahead, please hold (demo mode)', '3 people ahead, please wait (demo mode)', '1 person ahead, almost there (demo mode)', 'Connecting you now... (demo mode)'],
    };
    const busyMsg = {
        zh: '当前人工客服繁忙，已恢复AI客服为您服务（演示模式）',
        yue: '當前人工客服繁忙，已恢復AI客服為您服務（演示模式）',
        en: 'All agents busy. AI assistant resumed for you. (demo mode)',
    };
    const steps = queueSteps[USER_CFG.lang] || queueSteps.zh;
    const noticeId = '_transfer_queue';

    for (let i = 0; i < steps.length; i++) {
        await new Promise(r => setTimeout(r, 2000));
        if (STATE.callState !== 'transferring') return;
        setSystemNotice(steps[i], noticeId);
    }

    // 排队失败，恢复 AI 对话
    await new Promise(r => setTimeout(r, 2000));
    if (STATE.callState !== 'transferring') return;
    removeSystemNotice(noticeId);
    const failMsg = busyMsg[USER_CFG.lang] || busyMsg.zh;
    setSystemNotice(failMsg, '_transfer_result');

    // 重新启动 AI 对话
    try {
        const body = {
            RoomId: STATE.roomId,
            AgentConfig: {
                UserId: STATE.robotUserId,
                UserSig: STATE.robotUserSig,
                TargetUserId: STATE.userId,
                Lang: USER_CFG.lang,
            },
            UserConfig: {
                TurnDetectionMode: USER_CFG.turnDetectionMode,
                InterruptMode: USER_CFG.interruptMode,
                InterruptSpeechDuration: USER_CFG.interruptSpeechDuration,
                VadLevel: USER_CFG.vadLevel,
                VadSilenceTime: USER_CFG.vadSilenceTime,
                Gender: STATE.gender,
            },
            UseAvatar: STATE.useAvatar,
            AvatarConfig: {
                AvatarUserID: STATE.avatarUserId,
                AvatarUserSig: STATE.avatarUserSig,
            },
        };
        const startRsp = await postAction('StartAIConversation', body);
        if (startRsp.TaskId) {
            STATE.taskId = startRsp.TaskId;
            STATE.callState = 'active';
            console.log('✅ AI resumed, TaskId=' + STATE.taskId);
            return;
        }
    } catch (e) { console.warn('resume AI failed', e); }

    STATE.callState = 'active';
}

// ========== 订单面板 ==========
function toggleOrderPanel() {
    const panel = $('order-panel');
    panel.classList.toggle('active');
    if (panel.classList.contains('active')) renderOrderList();
}

function renderOrderList() {
    const list = $('order-list');
    list.innerHTML = MOCK_ORDERS.map(order => {
        const name = order.name[USER_CFG.lang] || order.name.zh;
        const price = order.price[USER_CFG.lang] || order.price.zh;
        const statusKey = 'orderStatus_' + order.status;
        const statusText = I18N.t(statusKey);
        const statusCls = 'order-status-' + order.status;
        return `
            <div class="order-item">
                <img class="order-img" src="${order.img}" alt="">
                <div class="order-info">
                    <div class="order-name">${escapeHtml(name)}</div>
                    <div class="order-meta">${order.id} · ${order.date}</div>
                    <div class="order-bottom">
                        <span class="order-price">${price}</span>
                        <span class="order-status ${statusCls}">${statusText}</span>
                    </div>
                </div>
                <button class="order-send-btn" onclick="sendOrderCard('${order.id}')">${I18N.t('orderSend')}</button>
            </div>
        `;
    }).join('');
}

function sendOrderCard(orderId) {
    const order = MOCK_ORDERS.find(o => o.id === orderId);
    if (!order || !trtcClient || STATE.callState !== 'active') return;

    const name = order.name[USER_CFG.lang] || order.name.zh;
    const price = order.price[USER_CFG.lang] || order.price.zh;
    const statusText = I18N.t('orderStatus_' + order.status);

    // UI 显示订单卡片气泡
    const cardMsg = {
        id: uuid(),
        role: 'user',
        text: '',
        time: Date.now(),
        streaming: false,
        card: { ...order, localName: name, localPrice: price, statusText },
    };
    STATE.messages.push(cardMsg);
    renderAllMessages();

    // 发送纯文本给 AI（让 AI 理解订单上下文，语言与当前模式一致）
    let textForAI;
    if (USER_CFG.lang === 'en') {
        textForAI = `[Order Info] Order ID: ${order.id}, Product: ${name}, Price: ${price}, Status: ${statusText}, Date: ${order.date}`;
    } else if (USER_CFG.lang === 'yue') {
        textForAI = `[訂單信息] 訂單號: ${order.id}, 商品: ${name}, 價格: ${price}, 狀態: ${statusText}, 落單日期: ${order.date}`;
    } else {
        textForAI = `[订单信息] 订单号: ${order.id}, 商品: ${name}, 价格: ${price}, 状态: ${statusText}, 下单日期: ${order.date}`;
    }
    STATE.addedUserTexts.add(textForAI);

    const message = {
        type: 20000,
        sender: STATE.userId,
        receiver: [STATE.robotUserId],
        payload: { id: uuid(), message: textForAI, timestamp: Date.now() },
    };
    try {
        trtcClient.sendCustomMessage({
            cmdId: 2,
            data: new TextEncoder().encode(JSON.stringify(message)).buffer,
        });
        console.log('📦 order card sent:', orderId);
    } catch (e) { console.error('sendOrderCard failed', e); }

    // 关闭订单面板
    $('order-panel').classList.remove('active');
}

// ========== 告别 + 结束 ==========
async function farewellAndEnd() {
    if (STATE.callState !== 'active') return;
    STATE.callState = 'ending';

    const farewell = STATE.farewellMessage[USER_CFG.lang]
        || (USER_CFG.lang === 'en'
            ? 'Alright, thank you. Goodbye!'
            : '好的，感谢咨询，再见！');

    // 等待 AI 机器人退房（StopAfterPlay=true → 播报完 → 任务结束 → 机器人退房）
    const robotLeavePromise = new Promise(resolve => {
        STATE._onRobotLeave = resolve;
        setTimeout(() => { STATE._onRobotLeave = null; resolve(); }, 30000); // 兜底超时 30s
    });
    try {
        await postAction('FarewellAndStop', {
            TaskId: STATE.taskId,
            Lang: USER_CFG.lang,
            FarewellText: farewell,
        });
    } catch (e) { console.warn('FarewellAndStop failed', e); }

    await robotLeavePromise;

    try {
        if (trtcClient) {
            await trtcClient.exitRoom();
            trtcClient.destroy();
            trtcClient = null;
        }
    } catch (e) { console.warn('exitRoom failed', e); }

    exitAvatarMode();
    STATE.callState = 'idle';
    showRatingModal();
}

// ========== 手动打断 ==========
function sendInterrupt() {
    if (!trtcClient || STATE.callState !== 'active') return;
    const message = {
        type: 20001,
        sender: STATE.userId,
        receiver: [STATE.robotUserId],
        payload: {
            id: uuid(),
            timestamp: Date.now(),
        },
    };
    try {
        trtcClient.sendCustomMessage({
            cmdId: 2,
            data: new TextEncoder().encode(JSON.stringify(message)).buffer,
        });
        console.log('✋ interrupt signal sent');
    } catch (e) { console.error('sendInterrupt failed', e); }
}

// ========== 键盘输入：直连 LLM ==========
/**
 * 发送文字消息给 AI（type=20000 自定义消息，服务端跳过 ASR 直送 LLM）
 * 参考：https://cloud.tencent.com/document/product/647/115412
 */
async function sendTextMessage() {
    const text = els.textInput.value.trim();
    if (!text || !trtcClient || STATE.callState !== 'active') return;

    // 1) UI 先展示用户消息
    STATE.addedUserTexts.add(text);
    addMessage('user', text);
    els.textInput.value = '';

    // 2) 检测转人工关键词（优先）
    const transferHit = detectTransferKeyword(text);
    if (transferHit) {
        console.log(`🔄 文本转人工关键词命中: "${transferHit}"`);
        await triggerTransfer();
        return;
    }

    // 3) 检测结束关键词
    const hit = detectEndKeyword(text);
    if (hit) {
        console.log(`🛑 文本关键词命中: "${hit}"`);
        await farewellAndEnd();
        return;
    }

    // 3) 走 type=20000 发送给 AI Bot
    const message = {
        type: 20000,
        sender: STATE.userId,
        receiver: [STATE.robotUserId],
        payload: {
            id: uuid(),
            message: text,
            timestamp: Date.now(),
        },
    };
    try {
        await trtcClient.sendCustomMessage({
            cmdId: 2,
            data: new TextEncoder().encode(JSON.stringify(message)).buffer,
        });
        console.log('📤 text message sent:', text);
    } catch (e) {
        console.error('sendCustomMessage failed', e);
    }
}

// ========== 输入模式切换 ==========
function switchInputMode(mode) {
    USER_CFG.inputMode = mode;
    if (mode === 'text') {
        els.voicePanel.classList.add('hidden');
        els.textPanel.classList.remove('hidden');
        els.modeIconKeyboard.style.display = 'none';
        els.modeIconMic.style.display = 'block';
        setTimeout(() => els.textInput.focus(), 50);
    } else {
        els.voicePanel.classList.remove('hidden');
        els.textPanel.classList.add('hidden');
        els.modeIconKeyboard.style.display = 'block';
        els.modeIconMic.style.display = 'none';
    }
}

// ========== 手动评分弹窗 ==========
const RATING_DIMENSIONS = ['overall', 'resolution', 'professionalism', 'friendliness'];
const RATING_DIM_I18N = {
    overall: 'ratingDimOverall',
    resolution: 'ratingDimResolution',
    professionalism: 'ratingDimProfessionalism',
    friendliness: 'ratingDimFriendliness',
};
const USER_RATING = {};

function renderRatingBlocks() {
    els.ratingDimensions.innerHTML = RATING_DIMENSIONS.map(dim => `
        <div class="rating-dimension" data-dim="${dim}">
            <div class="rating-dim-header">
                <span class="rating-dim-name">${I18N.t(RATING_DIM_I18N[dim])}</span>
                <span class="rating-dim-value empty" id="rating-val-${dim}">—</span>
            </div>
            <div class="rating-blocks" data-dim="${dim}">
                ${Array.from({ length: 11 }, (_, i) =>
                    `<div class="rating-block" data-score="${i}">${i}</div>`
                ).join('')}
            </div>
        </div>
    `).join('');

    // 刷新某维度方块的高亮状态（0 ~ score 区间着色）
    function refreshDimBlocks(container, score) {
        container.querySelectorAll('.rating-block').forEach(b => {
            const s = parseInt(b.dataset.score, 10);
            b.classList.toggle('selected', s <= score);
        });
    }

    els.ratingDimensions.querySelectorAll('.rating-blocks').forEach(row => {
        // hover 预览：鼠标移入时临时高亮区间
        row.addEventListener('mouseover', (e) => {
            const block = e.target.closest('.rating-block');
            if (!block) return;
            const hoverScore = parseInt(block.dataset.score, 10);
            refreshDimBlocks(row, hoverScore);
        });
        // hover 离开：恢复到已选分数（无选择则清除所有）
        row.addEventListener('mouseleave', () => {
            const dim = row.dataset.dim;
            const selected = USER_RATING[dim];
            if (typeof selected !== 'undefined') {
                refreshDimBlocks(row, selected);
            } else {
                row.querySelectorAll('.rating-block').forEach(b => b.classList.remove('selected'));
            }
        });
    });

    els.ratingDimensions.querySelectorAll('.rating-block').forEach(block => {
        block.addEventListener('click', () => {
            const score = parseInt(block.dataset.score, 10);
            const dim = block.parentElement.dataset.dim;
            USER_RATING[dim] = score;
            refreshDimBlocks(block.parentElement, score);
            // 显示分数
            const valEl = $(`rating-val-${dim}`);
            valEl.textContent = score;
            valEl.classList.remove('empty');
            // 检查是否至少"整体满意度"已评
            els.btnRatingSubmit.disabled = typeof USER_RATING.overall === 'undefined';
        });
    });
}

function showRatingModal() {
    els.ratingOverlay.classList.add('active');
    els.ratingMain.classList.remove('hidden');
    els.ratingThanks.classList.remove('active');
    Object.keys(USER_RATING).forEach(k => delete USER_RATING[k]);
    renderRatingBlocks();
    els.btnRatingSubmit.disabled = true;
}

function submitRating() {
    // 至少要评 overall
    if (typeof USER_RATING.overall === 'undefined') return;
    console.log('⭐ user rating:', USER_RATING);
    // 展示感谢页
    els.ratingMain.classList.add('hidden');
    els.ratingThanks.classList.add('active');
    els.ratingThanksDesc.textContent = I18N.t('ratingThanksDesc', { overall: USER_RATING.overall });
}

function closeRating() {
    els.ratingOverlay.classList.remove('active');
    // 回到客服选择
    els.chatArea.classList.remove('active');
    els.chatArea.classList.remove('avatar-mode');
    if (els.avatarVideoLayer) els.avatarVideoLayer.classList.remove('active');
    els.agentSelectArea.style.display = 'flex';
    els.btnStart.disabled = false;
    // 重置输入模式
    switchInputMode('voice');
    // 重置麦克风/静音状态
    STATE.muted = false;
    STATE.localMuted = false;
    const btnMuteEl = $('btn-mute');
    if (btnMuteEl) { btnMuteEl.classList.remove('muted'); btnMuteEl.title = I18N.t('btnMute'); }
    const micBtnEl = $('mic-btn');
    if (micBtnEl) micBtnEl.classList.remove('muted');
}

// ========== 静音切换 ==========
function toggleMute() {
    if (!trtcClient || STATE.callState !== 'active') return;
    STATE.muted = !STATE.muted;
    trtcClient.setRemoteAudioVolume(STATE.robotUserId, STATE.muted ? 0 : 100);
    const btn = $('btn-mute');
    if (btn) {
        btn.classList.toggle('muted', STATE.muted);
        btn.title = STATE.muted ? I18N.t('btnUnmute') : I18N.t('btnMute');
    }
    console.log('🔇 remote mute:', STATE.muted);
}

function toggleLocalMute() {
    if (!trtcClient || STATE.callState !== 'active') return;
    STATE.localMuted = !STATE.localMuted;
    trtcClient.updateLocalAudio({ mute: STATE.localMuted });
    const btn = $('mic-btn');
    if (btn) btn.classList.toggle('muted', STATE.localMuted);
    console.log('🎤 local mute:', STATE.localMuted);
}

// ========== Mobile detection ==========
function isMobile() { return window.innerWidth <= 768; }

function toggleSidebar(forceCollapse) {
    const shouldCollapse = typeof forceCollapse === 'boolean'
        ? forceCollapse
        : !els.sidebar.classList.contains('collapsed');
    els.sidebar.classList.toggle('collapsed', shouldCollapse);
    if (els.sidebarOverlay) {
        els.sidebarOverlay.classList.toggle('active', !shouldCollapse && isMobile());
    }
    els.toggleSidebarText.textContent = I18N.t(shouldCollapse ? 'expandConfig' : 'collapseConfig');
}

// ========== 事件绑定 ==========
function bindEvents() {
    els.btnToggleSidebar.addEventListener('click', () => toggleSidebar());
    // 遮罩层点击关闭侧边栏
    if (els.sidebarOverlay) {
        els.sidebarOverlay.addEventListener('click', () => toggleSidebar(true));
    }
    // 移动端默认收起侧边栏
    if (isMobile()) toggleSidebar(true);

    // Pill 切换
    const bindPill = (container, cfgKey, parser = (v) => v) => {
        container.querySelectorAll('.pill').forEach(btn => {
            btn.addEventListener('click', () => {
                if (btn.disabled) return;
                container.querySelectorAll('.pill').forEach(b => b.classList.remove('active'));
                btn.classList.add('active');
                USER_CFG[cfgKey] = parser(btn.dataset.value);
            });
        });
    };
    bindPill(els.modeSwitch, 'mode');
    bindPill(els.turnSwitch, 'turnDetectionMode', (v) => parseInt(v, 10));
    bindPill(els.interruptSwitch, 'interruptMode', (v) => {
        const mode = parseInt(v, 10);
        // 联动启用/禁用打断响应延迟滑块
        setTimeout(() => {
            const enabled = mode === 0;
            els.groupInterruptDuration.classList.toggle('slider-disabled', !enabled);
            els.sliderInterruptDuration.disabled = !enabled;
            updateAIForConfigChange();
        }, 0);
        return mode;
    });

    // 基础设置折叠
    const basicToggle = $('basic-toggle');
    const basicContainer = $('basic-container');
    basicToggle.addEventListener('click', () => {
        basicToggle.classList.toggle('collapsed');
        basicContainer.style.display = basicToggle.classList.contains('collapsed') ? 'none' : 'block';
    });

    // 高级设置折叠
    els.advToggle.addEventListener('click', () => {
        els.advToggle.classList.toggle('collapsed');
        els.advContainer.style.display = els.advToggle.classList.contains('collapsed') ? 'none' : 'block';
    });

    // 滑块
    const bindSlider = (slider, valEl, key, unit = '') => {
        slider.addEventListener('input', () => {
            valEl.innerHTML = slider.value + (unit ? `<span class="slider-unit">${unit}</span>` : '');
            USER_CFG[key] = parseInt(slider.value, 10);
        });
    };
    bindSlider(els.sliderVadSilence, els.valVadSilence, 'vadSilenceTime', 'ms');
    bindSlider(els.sliderVadLevel, els.valVadLevel, 'vadLevel');
    bindSlider(els.sliderInterruptDuration, els.valInterruptDuration, 'interruptSpeechDuration', 'ms');

    // 打断时长滑块松手时实时更新 AI 配置
    if (els.sliderInterruptDuration) {
        els.sliderInterruptDuration.addEventListener('change', () => updateAIForConfigChange());
    }

    // 客服选择
    els.agentCards.querySelectorAll('.agent-card').forEach(card => {
        card.addEventListener('click', () => {
            els.agentCards.querySelectorAll('.agent-card').forEach(c => c.classList.remove('selected'));
            card.classList.add('selected');
            STATE.gender = card.dataset.gender;
        });
    });

    els.btnStart.addEventListener('click', startCall);
    els.btnEnd.addEventListener('click', farewellAndEnd);

    // 静音按钮
    const btnMute = $('btn-mute');
    if (btnMute) btnMute.addEventListener('click', toggleMute);
    // 麦克风静音按钮
    const micBtn = $('mic-btn');
    if (micBtn) micBtn.addEventListener('click', toggleLocalMute);

    // 订单按钮
    const btnOrders = $('btn-orders');
    if (btnOrders) btnOrders.addEventListener('click', toggleOrderPanel);
    // 转人工按钮
    const btnTransfer = $('btn-transfer');
    if (btnTransfer) btnTransfer.addEventListener('click', triggerTransfer);

    // 输入模式切换
    els.btnInputModeSwitch.addEventListener('click', () => {
        switchInputMode(USER_CFG.inputMode === 'voice' ? 'text' : 'voice');
    });

    // 文字输入（Enter 发送）
    els.textInput.addEventListener('keydown', (e) => {
        if (e.key === 'Enter' && !e.shiftKey) {
            e.preventDefault();
            sendTextMessage();
        }
    });

    // 评分弹窗
    els.btnRatingSkip.addEventListener('click', closeRating);
    els.btnRatingSubmit.addEventListener('click', submitRating);
    els.btnRatingClose.addEventListener('click', closeRating);
    els.btnRatingRestart.addEventListener('click', closeRating);

    // 语言变化
    window.addEventListener('langChange', () => {
        USER_CFG.lang = I18N.getLang();
        applyI18n();
        // 同步头像图（语言影响欧美/亚洲脸）
        updateAvatarImages();
        // 对话中切换语言 → 更新订单列表 + 聊天头像 + 调用 UpdateAIConversation
        if (STATE.callState === 'active' && STATE.taskId) {
            // 重新渲染消息（更新聊天中的客服头像）
            renderAllMessages();
            // 刷新订单面板（如果打开中）
            const orderPanel = $('order-panel');
            if (orderPanel && orderPanel.classList.contains('active')) renderOrderList();
            // 调用后端更新 AI 对话配置（TTSConfig + LLMConfig + STTConfig）
            updateAIForLangSwitch();
        }
    });

    // 语言下拉框交互
    if (els.langToggle) {
        els.langToggle.addEventListener('click', (e) => {
            e.stopPropagation();
            els.langDropdown.classList.toggle('open');
        });
    }
    if (els.langMenu) {
        els.langMenu.addEventListener('click', (e) => {
            const item = e.target.closest('.lang-dropdown-item');
            if (!item) return;
            const lang = item.dataset.lang;
            if (lang) I18N.setLang(lang);
            els.langDropdown.classList.remove('open');
        });
    }
    // 点击其他区域关闭下拉框
    document.addEventListener('click', () => {
        if (els.langDropdown) els.langDropdown.classList.remove('open');
    });
}

// ========== 头像图随语言切换 ==========
function updateAvatarImages() {
    const lang = USER_CFG.lang === 'yue' ? 'zh' : USER_CFG.lang;
    els.avatarImgFemale.src = `/static/avatars/${lang}_female.png`;
    els.avatarImgFemale.onerror = function() { avatarOnError(this); };
    els.avatarImgMale.src = `/static/avatars/${lang}_male.png`;
    els.avatarImgMale.onerror = function() { avatarOnError(this); };
}

// ========== 对话中切换语言 → 更新 AI 配置 ==========
async function updateAIForLangSwitch() {
    if (!STATE.taskId || STATE.callState !== 'active') return;
    try {
        const rsp = await postAction('UpdateAIConversation', {
            TaskId: STATE.taskId,
            Lang: USER_CFG.lang,
            Gender: STATE.gender,
            UpdateWelcome: true,
        });
        if (rsp.Response?.Error) {
            console.warn('UpdateAIConversation failed:', rsp.Response.Error.Message);
        } else {
            console.log('🔄 AI config updated for lang:', USER_CFG.lang);
        }
    } catch (e) {
        console.warn('UpdateAIConversation error:', e);
    }
}

// ========== 对话中调整侧边栏配置 → 实时更新 ==========
async function updateAIForConfigChange() {
    if (!STATE.taskId || STATE.callState !== 'active') return;
    try {
        const rsp = await postAction('UpdateAIConversation', {
            TaskId: STATE.taskId,
            InterruptMode: USER_CFG.interruptMode,
            InterruptSpeechDuration: USER_CFG.interruptSpeechDuration,
        });
        if (rsp.Response?.Error) {
            console.warn('UpdateAIConversation config failed:', rsp.Response.Error.Message);
        } else {
            console.log('🔄 AI config updated: interrupt=%d/%dms', USER_CFG.interruptMode, USER_CFG.interruptSpeechDuration);
        }
    } catch (e) {
        console.warn('UpdateAIConversation config error:', e);
    }
}

// ========== 数字人视频模式 ==========
function enterAvatarMode() {
    if (!els.avatarVideoLayer || !els.chatArea) return;
    els.avatarVideoLayer.classList.add('active');
    els.chatArea.classList.add('avatar-mode');
    console.log('🎬 Entered avatar video mode');
}

function exitAvatarMode() {
    if (!els.avatarVideoLayer || !els.chatArea) return;
    els.avatarVideoLayer.classList.remove('active');
    els.chatArea.classList.remove('avatar-mode');
    // 停止远端视频
    if (trtcClient && STATE.avatarUserId) {
        try { trtcClient.stopRemoteVideo({ userId: STATE.avatarUserId }); } catch (e) {}
    }
    console.log('🎬 Exited avatar video mode');
}

// ========== i18n ==========
function applyI18n() {
    document.querySelectorAll('[data-i18n]').forEach(el => {
        const key = el.getAttribute('data-i18n');
        el.textContent = I18N.t(key);
    });
    document.title = I18N.t('appName');
    if (els.textInput) els.textInput.placeholder = I18N.t('inputPlaceholder');
    // 语言下拉框更新
    const langMeta = { zh: { flag: '🇨🇳', label: '简体中文' }, yue: { flag: '🇭🇰', label: '粵語' }, en: { flag: '🇺🇸', label: 'English' } };
    const meta = langMeta[USER_CFG.lang] || langMeta.zh;
    if (els.langFlag) els.langFlag.textContent = meta.flag;
    if (els.langSwitchLabel) els.langSwitchLabel.textContent = meta.label;
    // 更新下拉菜单选中态
    document.querySelectorAll('.lang-dropdown-item').forEach(item => {
        item.classList.toggle('active', item.dataset.lang === USER_CFG.lang);
    });
    // tooltip 说明图标
    document.querySelectorAll('.tip-icon[data-tip-key]').forEach(el => {
        el.dataset.tip = I18N.t(el.dataset.tipKey);
    });
    // 收起/展开按钮文案
    if (els.toggleSidebarText) {
        const collapsed = els.sidebar.classList.contains('collapsed');
        els.toggleSidebarText.textContent = I18N.t(collapsed ? 'expandConfig' : 'collapseConfig');
    }
    // 数字人按钮可用性
    if (els.modeAvatarBtn) {
        const available = STATE.avatarAvailable;
        els.modeAvatarBtn.disabled = !available;
        els.modeAvatarBtn.textContent = available ? I18N.t('modeAvatar') : I18N.t('modeAvatarDisabled');
        if (!available && USER_CFG.mode === 'avatar') {
            USER_CFG.mode = 'voice';
            els.modeSwitch.querySelectorAll('.pill').forEach(b => {
                b.classList.toggle('active', b.dataset.value === 'voice');
            });
        }
    }
}

// ========== 启动前探测数字人可用性 ==========
async function probeAvatarAvailability() {
    try {
        const rsp = await postAction('join', { userid: 'probe' + randomId(4) });
        if (rsp && typeof rsp.avatar_available !== 'undefined') {
            STATE.avatarAvailable = !!rsp.avatar_available;
            STATE.endKeywords = rsp.end_keywords || STATE.endKeywords;
            STATE.farewellMessage = rsp.farewell_message || STATE.farewellMessage;
            STATE.transferKeywords = rsp.transfer_keywords || STATE.transferKeywords;
            STATE.transferMessage = rsp.transfer_message || STATE.transferMessage;
        }
    } catch (e) { console.warn('probe failed', e); }
    finally { applyI18n(); }
}

// ========== 初始化 ==========
function init() {
    USER_CFG.lang = I18N.getLang();
    updateAvatarImages();
    bindEvents();
    applyI18n();
    probeAvatarAvailability();

    // --- 移动端增强 ---
    if (isMobile()) {
        // Tooltip: 点击触发（移动端无 hover）
        document.querySelectorAll('.tip-icon').forEach(el => {
            el.addEventListener('click', (e) => {
                e.stopPropagation();
                const wasActive = el.classList.contains('touched');
                document.querySelectorAll('.tip-icon.touched').forEach(t => t.classList.remove('touched'));
                if (!wasActive) el.classList.add('touched');
            });
        });
        document.addEventListener('click', () => {
            document.querySelectorAll('.tip-icon.touched').forEach(t => t.classList.remove('touched'));
        });

        // iOS Safari: 文字输入聚焦时滚动到可见区域
        if (els.textInput) {
            els.textInput.addEventListener('focus', () => {
                setTimeout(() => {
                    els.textInput.scrollIntoView({ behavior: 'smooth', block: 'center' });
                }, 300);
            });
        }

        // 开始对话时自动收起侧边栏
        els.btnStart.addEventListener('click', () => toggleSidebar(true), { capture: true });
    }

    // 屏幕旋转/尺寸变化时同步遮罩状态
    window.addEventListener('resize', () => {
        if (!isMobile() && els.sidebarOverlay) {
            els.sidebarOverlay.classList.remove('active');
        }
    });
}

document.addEventListener('DOMContentLoaded', init);


path: assets/static/i18n.js
content: // =====================================================================
// AI 智能客服 - 国际化字典（IM 气泡风格 + 手动评分）
// =====================================================================
(function () {
    const DICT = {
        zh: {
            appName: 'AI 电商客服',
            langSwitch: 'EN',

            // 配置
            collapseConfig: '收起配置',
            expandConfig: '展开配置',
            config: '基础设置',
            sectionLanguage: '语言',
            sectionMode: '对话模式',
            modeVoice: '语音',
            modeAvatar: '数智人',
            modeAvatarDisabled: '数智人 (未配置)',
            sectionTurnMode: '断句模式',
            turnSemantic: '语义断句',
            turnVad: '普通断句',
            sectionInterrupt: '打断模式',
            interruptSmart: '智能打断',
            interruptManual: '手动打断',
            configMore: '其他设置',
            sectionStt: '语音识别引擎',
            sectionVadLevel: '远场人声抑制等级',
            tipVadLevel: '远场人声抑制等级用于控制远场人声抑制能力，等级越高远场人声抑制能力越强，注意较高等级可能会将单字当作噪声过滤。',
            sectionVadSilenceTime: 'VAD 时长 (ms)',
            tipVadSilenceTime: 'VAD 时长用于控制 AI 在用户说话时的响应速度，数值越小反应越快，越大越避免 AI 抢话。',
            sectionInterruptDuration: '打断时长 (ms)',
            tipInterruptDuration: '打断时长用于控制智能打断模式下 AI 被打断的灵敏度，数值越小越灵敏，越大越避免误打断。',
            tipInterruptDurationOnlySmart: '* 仅"智能打断"模式生效',
            sliderFaster: '更快',
            sliderSlower: '更慢',
            sliderWeaker: '更弱',
            sliderStronger: '更强',
            sliderSensitive: '更灵敏',
            sliderStable: '更稳定',
            btnMute: '静音',
            btnUnmute: '取消静音',

            // 客服选择
            sectionAgentSelect: '请选择客服',
            agentFemaleName: '客服 001 号',
            agentMaleName: '客服 002 号',
            btnStart: '开始对话',

            // 对话状态
            tipConnecting: '正在连接...',
            tipConnected: '已连接',
            tipEnterRoomFailed: '进房失败，请检查网络与权限',
            tipMicrophoneFailed: '麦克风启动失败，请允许麦克风权限',
            tipAIStartFailed: '启动 AI 客服失败：',
            tipSdkLoadFailed: 'TRTC SDK 加载失败',

            // AI 状态
            statusOffline: '未连接',
            statusConnecting: '连接中',
            statusListening: '聆听中',
            statusThinking: '思考中',
            statusSpeaking: '说话中',
            statusInterrupted: '已打断',
            statusFinished: '已结束',

            // 气泡与输入
            welcomePlaceholder: '你好！有什么我可以帮您的吗？',
            typing: '正在输入',
            btnInterrupt: '点击打断',
            inputPlaceholder: '请输入文字与 AI 客服交流...',
            inputVoiceHint: '🎤 语音对话中，或点击键盘图标切换文字输入',
            btnEnd: '结束',
            btnSend: '发送',
            btnVoiceMode: '语音',
            btnKeyboardMode: '文字',

            // 手动评分弹窗
            ratingTitle: '请为本次服务评分',
            ratingSubtitle: '您的反馈将帮助我们改进服务质量',
            ratingScoreHint: '（0 最差，10 最优）',
            ratingDimOverall: '整体满意度',
            ratingDimResolution: '问题解决度',
            ratingDimProfessionalism: '响应专业性',
            ratingDimFriendliness: '沟通友好度',
            ratingBtnSkip: '跳过',
            ratingBtnSubmit: '提交评分',
            ratingThanks: '感谢您的评价！',
            ratingThanksDesc: '您的 {overall} 分反馈已收到',
            ratingBtnRestart: '再次咨询',
            ratingBtnClose: '关闭',

            // 角色
            roleAgent: 'AI 客服',
            roleUser: '我',

            // 电商功能
            btnOrders: '订单',
            orderPanelTitle: '选择订单',
            orderSend: '发送',
            orderStatus_pending: '待发货',
            orderStatus_shipped: '运输中',
            orderStatus_delivered: '已签收',
            orderStatus_refunding: '退款中',
            btnTransfer: '转人工',
            transferTitle: '转接人工客服',
            transferDesc: '已加入人工客服排队（演示模式，生产环境请对接客服路由系统）。',
            transferBtnBack: '返回首页',
        },
        en: {
            appName: 'AI E-Commerce Service',
            langSwitch: '中',

            collapseConfig: 'Collapse',
            expandConfig: 'Expand',
            config: 'Basic Settings',
            sectionLanguage: 'Language',
            sectionMode: 'Mode',
            modeVoice: 'Audio',
            modeAvatar: 'Avatar',
            modeAvatarDisabled: 'Avatar (Disabled)',
            sectionTurnMode: 'Segmentation Mode',
            turnSemantic: 'Semantic',
            turnVad: 'VAD',
            sectionInterrupt: 'Interrupt Mode',
            interruptSmart: 'Intelligent',
            interruptManual: 'Manual',
            configMore: 'Advanced',
            sectionStt: 'ASR Engine',
            sectionVadLevel: 'Far-field Suppression',
            tipVadLevel: 'Controls far-field voice suppression. Higher levels provide stronger suppression, but may filter out single-word utterances as noise.',
            sectionVadSilenceTime: 'VAD Duration (ms)',
            tipVadSilenceTime: "VAD duration controls the AI's response speed when you speak. Smaller values mean faster responses, while larger values prevent the AI from interrupting.",
            sectionInterruptDuration: 'Interruption Duration (ms)',
            tipInterruptDuration: 'Interruption duration controls AI sensitivity in intelligent interruption mode. Smaller values increase sensitivity, while larger values reduce false interruptions.',
            tipInterruptDurationOnlySmart: '* Only effective in "Intelligent" mode',
            sliderFaster: 'Faster',
            sliderSlower: 'Slower',
            sliderWeaker: 'Weaker',
            sliderStronger: 'Stronger',
            sliderSensitive: 'Sensitive',
            sliderStable: 'Stable',
            btnMute: 'Mute',
            btnUnmute: 'Unmute',

            sectionAgentSelect: 'Choose Your Agent',
            agentFemaleName: 'Agent 001',
            agentMaleName: 'Agent 002',
            btnStart: 'Start Chat',

            tipConnecting: 'Connecting...',
            tipConnected: 'Connected',
            tipEnterRoomFailed: 'Failed to enter room.',
            tipMicrophoneFailed: 'Microphone failed. Allow mic permission.',
            tipAIStartFailed: 'Failed to start AI: ',
            tipSdkLoadFailed: 'TRTC SDK load failed',

            statusOffline: 'Offline',
            statusConnecting: 'Connecting',
            statusListening: 'Listening',
            statusThinking: 'Thinking',
            statusSpeaking: 'Speaking',
            statusInterrupted: 'Interrupted',
            statusFinished: 'Finished',

            welcomePlaceholder: "Hi! How can I help you today?",
            typing: 'typing',
            btnInterrupt: 'Click to interrupt',
            inputPlaceholder: 'Type to chat with AI agent...',
            inputVoiceHint: '🎤 Voice chat active, or click keyboard icon to type',
            btnEnd: 'End',
            btnSend: 'Send',
            btnVoiceMode: 'Voice',
            btnKeyboardMode: 'Text',

            ratingTitle: 'Please Rate This Service',
            ratingSubtitle: 'Your feedback helps us improve',
            ratingScoreHint: '(0 worst, 10 best)',
            ratingDimOverall: 'Overall Satisfaction',
            ratingDimResolution: 'Resolution',
            ratingDimProfessionalism: 'Professionalism',
            ratingDimFriendliness: 'Friendliness',
            ratingBtnSkip: 'Skip',
            ratingBtnSubmit: 'Submit',
            ratingThanks: 'Thank you for your feedback!',
            ratingThanksDesc: 'Your rating of {overall}/10 has been received',
            ratingBtnRestart: 'New Chat',
            ratingBtnClose: 'Close',

            roleAgent: 'AI Agent',
            roleUser: 'You',

            btnOrders: 'Orders',
            orderPanelTitle: 'Select Order',
            orderSend: 'Send',
            orderStatus_pending: 'Pending',
            orderStatus_shipped: 'Shipped',
            orderStatus_delivered: 'Delivered',
            orderStatus_refunding: 'Refunding',
            btnTransfer: 'Agent',
            transferTitle: 'Transfer to Human Agent',
            transferDesc: 'Added to queue (demo mode — connect to your agent routing system in production).',
            transferBtnBack: 'Back to Home',
        },
        yue: {
            appName: 'AI 電商客服',
            langSwitch: '粤',

            collapseConfig: '收起配置',
            expandConfig: '展開配置',
            config: '基礎設置',
            sectionMode: '對話模式',
            modeVoice: '語音',
            modeAvatar: '數智人',
            modeAvatarDisabled: '數智人 (未配置)',
            sectionTurnMode: '斷句模式',
            turnSemantic: '語義斷句',
            turnVad: '普通斷句',
            sectionInterrupt: '打斷模式',
            interruptSmart: '智能打斷',
            interruptManual: '手動打斷',
            configMore: '其他設置',
            sectionVadLevel: '遠場人聲抑制等級',
            tipVadLevel: '遠場人聲抑制等級用於控制遠場人聲抑制能力，等級越高遠場人聲抑制能力越強，注意較高等級可能會將單字當作噪聲過濾。',
            sectionVadSilenceTime: 'VAD 時長 (ms)',
            tipVadSilenceTime: 'VAD 時長用於控制 AI 喺用戶講嘢時嘅響應速度，數值越小反應越快，越大越避免 AI 搶話。',
            sectionInterruptDuration: '打斷時長 (ms)',
            tipInterruptDuration: '打斷時長用於控制智能打斷模式下 AI 被打斷嘅靈敏度，數值越小越靈敏，越大越避免誤打斷。',
            tipInterruptDurationOnlySmart: '* 僅「智能打斷」模式生效',
            sliderFaster: '更快',
            sliderSlower: '更慢',
            sliderWeaker: '更弱',
            sliderStronger: '更強',
            sliderSensitive: '更靈敏',
            sliderStable: '更穩定',
            btnMute: '靜音',
            btnUnmute: '取消靜音',

            sectionAgentSelect: '請選擇客服',
            agentFemaleName: '客服 001 號',
            agentMaleName: '客服 002 號',
            btnStart: '開始對話',

            tipConnecting: '正在連接...',
            tipConnected: '已連接',
            tipEnterRoomFailed: '進房失敗，請檢查網絡與權限',
            tipMicrophoneFailed: '麥克風啟動失敗，請允許麥克風權限',
            tipAIStartFailed: '啟動 AI 客服失敗：',
            tipSdkLoadFailed: 'TRTC SDK 加載失敗',

            statusOffline: '未連接',
            statusConnecting: '連接中',
            statusListening: '聆聽中',
            statusThinking: '思考中',
            statusSpeaking: '講緊嘢',
            statusInterrupted: '已打斷',
            statusFinished: '已結束',

            welcomePlaceholder: '你好！有咩可以幫到您？',
            typing: '正在輸入',
            btnInterrupt: '點擊打斷',
            inputPlaceholder: '請輸入文字與 AI 客服交流...',
            inputVoiceHint: '🎤 語音對話中，或點擊鍵盤圖標切換文字輸入',
            btnEnd: '結束',
            btnSend: '發送',
            btnVoiceMode: '語音',
            btnKeyboardMode: '文字',

            ratingTitle: '請為本次服務評分',
            ratingSubtitle: '您嘅反饋將幫助我哋改進服務質量',
            ratingScoreHint: '（0 最差，10 最優）',
            ratingDimOverall: '整體滿意度',
            ratingDimResolution: '問題解決度',
            ratingDimProfessionalism: '響應專業性',
            ratingDimFriendliness: '溝通友好度',
            ratingBtnSkip: '跳過',
            ratingBtnSubmit: '提交評分',
            ratingThanks: '多謝您嘅評價！',
            ratingThanksDesc: '您嘅 {overall} 分反饋已收到',
            ratingBtnRestart: '再次諮詢',
            ratingBtnClose: '關閉',

            roleAgent: 'AI 客服',
            roleUser: '我',

            btnOrders: '訂單',
            orderPanelTitle: '選擇訂單',
            orderSend: '發送',
            orderStatus_pending: '待發貨',
            orderStatus_shipped: '運輸中',
            orderStatus_delivered: '已簽收',
            orderStatus_refunding: '退款中',
            btnTransfer: '轉人工',
            transferTitle: '轉接人工客服',
            transferDesc: '已加入人工客服排隊（演示模式，生產環境請對接客服路由系統）。',
            transferBtnBack: '返回首頁',
        },
    };

    const STORAGE_KEY = 'cs_lang';
    let currentLang = (function () {
        const saved = localStorage.getItem(STORAGE_KEY);
        if (saved && DICT[saved]) return saved;
        const browser = (navigator.language || 'zh').toLowerCase();
        return browser.startsWith('en') ? 'en' : 'zh';
    })();

    function t(key, params) {
        let text = (DICT[currentLang] && DICT[currentLang][key]) || key;
        if (params) {
            for (const k of Object.keys(params)) {
                text = text.replace('{' + k + '}', params[k]);
            }
        }
        return text;
    }
    function getLang() { return currentLang; }
    function setLang(lang) {
        if (!DICT[lang]) return;
        currentLang = lang;
        localStorage.setItem(STORAGE_KEY, lang);
        window.dispatchEvent(new CustomEvent('langChange', { detail: { lang } }));
    }
    const LANG_CYCLE = ['zh', 'yue', 'en'];
    function toggleLang() {
        const idx = LANG_CYCLE.indexOf(currentLang);
        setLang(LANG_CYCLE[(idx + 1) % LANG_CYCLE.length]);
    }

    window.I18N = { t, getLang, setLang, toggleLang, DICT };
    Object.defineProperty(window.I18N, 'currentLang', { get: () => currentLang });
})();


path: assets/static/mock-orders.json
content: [
    {
        "id": "CS20260510001",
        "name": { "zh": "无线降噪耳机 Pro", "yue": "無線降噪耳機 Pro", "en": "Wireless ANC Headphones Pro" },
        "price": { "zh": "¥699", "yue": "HK$759", "en": "$96" },
        "status": "shipped",
        "date": "2026-05-10",
        "emoji": "🎧"
    },
    {
        "id": "CS20260508002",
        "name": { "zh": "轻薄笔记本电脑 14寸", "yue": "輕薄筆記本電腦 14吋", "en": "Ultra-slim Laptop 14\"" },
        "price": { "zh": "¥5,299", "yue": "HK$5,749", "en": "$729" },
        "status": "delivered",
        "date": "2026-05-08",
        "emoji": "💻"
    },
    {
        "id": "CS20260507003",
        "name": { "zh": "智能手表 S9", "yue": "智能手錶 S9", "en": "Smart Watch S9" },
        "price": { "zh": "¥1,299", "yue": "HK$1,409", "en": "$179" },
        "status": "pending",
        "date": "2026-05-07",
        "emoji": "⌚"
    },
    {
        "id": "CS20260505004",
        "name": { "zh": "真皮双肩包", "yue": "真皮雙肩包", "en": "Genuine Leather Backpack" },
        "price": { "zh": "¥399", "yue": "HK$433", "en": "$55" },
        "status": "refunding",
        "date": "2026-05-05",
        "emoji": "🎒"
    },
    {
        "id": "CS20260501005",
        "name": { "zh": "空气炸锅 5L", "yue": "空氣炸鍋 5L", "en": "Air Fryer 5L" },
        "price": { "zh": "¥259", "yue": "HK$281", "en": "$36" },
        "status": "delivered",
        "date": "2026-05-01",
        "emoji": "🍳"
    }
]


path: references/architecture.md
content: # TRTC AI 智能客服 - 架构设计参考

## 目录

1. [系统架构](#系统架构)
2. [项目结构](#项目结构)
3. [后端实现详解](#后端实现详解)
4. [认证流程](#认证流程)
5. [核心设计模式](#核心设计模式)
6. [依赖清单](#依赖清单)
7. [启动与部署](#启动与部署)

---

## 系统架构

### 三平面模型

```
┌─────────────────────────────────────────────────┐
│              浏览器 (TRTC Web SDK v5)              │
│                                                   │
│   音频流 (WebRTC)   自定义消息 (字幕/状态/文字)       │
└───────────┬─────────────────┬─────────────────────┘
            │                 │
            ▼                 ▼
┌───────────────────────────────────────────────────┐
│                  TRTC Room (云端)                   │
│                                                     │
│   ASR ──► LLM ──► TTS ──► 推回房间                  │
│                                                     │
│              TRTC AI Bot (云端实例)                   │
└───────────┬─────────────────────────────────────────┘
            │ OpenAPI (TC3-HMAC-SHA256)
            ▼
┌───────────────────────────────────────────────────┐
│   Flask 后端 (app.py)                               │
│   - UserSig 签发                                    │
│   - TRTC OpenAPI 中转                               │
│   - 零 LLM 调用                                     │
└─────────────────────────────────────────────────────┘
```

关键洞察：**后端完全不调用 LLM**。LLM 推理由 TRTC 云端 AI Bot 内部完成。后端只做两件事：
1. 签发 UserSig（用于 TRTC 房间鉴权）
2. 中转 TRTC OpenAPI 请求（启动/停止/控制 AI 对话）

### 消息类型

| type | 方向 | 内容 |
|------|------|------|
| `10000` | 云→端 | 字幕（用户 ASR 结果 + AI 回复文本） |
| `10001` | 云→端 | AI 状态（聆听/思考/说话/打断/结束） |
| `20000` | 端→云 | 文字输入（跳过 ASR 直送 LLM） |
| `20001` | 端→云 | 手动打断 |

---

## 项目结构

```
project/
├── app.py                        # Flask 主入口（~296 行，5 个 Action 处理器）
├── config_loader.py              # 配置加载器（Region/TTS/STT 映射）
├── trtc_signer.py                # UserSig 三套签发封装
├── TLSSigAPIv2.py                # 腾讯云官方 TRTC HMAC 鉴权库（不修改）
├── env.example.yaml              # 配置模板
├── env.yaml                      # 实际配置（.gitignore 排除）
├── requirements.txt              # 4 个 Python 依赖
├── start.sh / start.bat          # 一键启动脚本
├── templates/
│   └── customer_service.html     # 主页面（配置侧边栏 + 聊天区 + 评分弹窗）
├── static/
│   ├── app.js                    # 前端核心交互逻辑
│   ├── i18n.js                   # 国际化字典（zh/yue/en）
│   └── avatars/                  # 客服头像
└── docs/
    └── PARAMS.md                 # 参数配置详解
```

---

## 后端实现详解

### 初始化

```python
# 加载配置
config = AppConfig("env.yaml")
signer = TRTCSigner(config.sdkappid, config.trtc_secret)

# 初始化 TRTC OpenAPI 客户端
_cred = credential.Credential(config.secret_id, config.secret_key)
_http = HttpProfile()
_http.endpoint = config.trtc_endpoint  # 按 Region 自动切换
_client_profile = ClientProfile()
_client_profile.httpProfile = _http
trtc_api = trtc_client.TrtcClient(_cred, config.trtc_region, _client_profile)
```

### 路由设计

仅 2 个路由，极简：
- `GET /` → 渲染主页面
- `POST /action` → 统一 API 调度，通过 `Action` 请求头区分

### Action 处理器详解

#### handle_join(data)

签发三套 UserSig 并下发配置：

```python
def handle_join(data):
    user_id = data.get("userid")
    sigs = signer.sign_trio(user_id)     # 用户 + 机器人 + 数字人
    avatar_enabled = config.is_avatar_enabled()
    return {
        "sdkappid": sigs["sdkappid"],
        "userid": sigs["user_id"],
        "usersig": sigs["user_sig"],
        "robot_userid": sigs["robot_user_id"],
        "robot_usersig": sigs["robot_user_sig"],
        "avatar_userid": sigs["avatar_user_id"] if avatar_enabled else "",
        "avatar_usersig": sigs["avatar_user_sig"] if avatar_enabled else "",
        "avatar_available": avatar_enabled,
        "end_keywords": config.all_end_keywords(),        # 三语结束关键词
        "farewell_message": {...},                         # 三语告别语
        "transfer_keywords": config.all_transfer_keywords(),  # 三语转人工关键词
        "transfer_message": {...},                         # 三语转接提示
    }
```

#### handle_start_ai_conversation(body)

组装 5 大配置块并调用 TRTC OpenAPI：

```python
def handle_start_ai_conversation(body):
    lang = body["AgentConfig"]["Lang"]          # zh/yue/en
    use_avatar = body.get("UseAvatar", False)

    # 1. AgentConfig：机器人身份 + 对话参数
    agent_cfg = {
        "UserId": body["AgentConfig"]["UserId"],
        "UserSig": body["AgentConfig"]["UserSig"],
        "TargetUserId": body["AgentConfig"]["TargetUserId"],
        "MaxIdleTime": 60,
        "WelcomeMessage": config.welcome_message(lang),
        "TurnDetectionMode": 3,                 # 语义断句
        "TurnDetection": {"SemanticEagerness": "auto"},
        "SubtitleMode": 1,                      # 句子级字幕
        "InterruptMode": interrupt_mode,         # 0=智能/1=手动
        "InterruptSpeechDuration": interrupt_speech_duration,
    }

    # 2. STTConfig：语音识别
    stt_cfg = {
        "Language": get_stt_language(lang),      # zh / zh-TW / en
        "VadLevel": vad_level,
        "VadSilenceTime": vad_silence_time,
    }

    # 3. LLMConfig：从 env.yaml 读取
    llm_cfg = config.llm_config(lang)            # 按语言切换 SystemPrompt

    # 4. TTSConfig
    if use_avatar:
        tts_cfg = {"TTSType": "dummy"}           # 数字人模式：TTS 由数字人引擎处理
    else:
        tts_cfg = {
            "TTSType": "flow",
            "Model": "flow_01_turbo",
            "VoiceId": get_voice_id(lang, gender),  # 6 种组合
        }

    # 5. AvatarConfig（可选）
    if use_avatar:
        params["AvatarConfig"] = json.dumps({...})

    # 调用 TRTC OpenAPI
    req = models.StartAIConversationRequest()
    req.from_json_string(json.dumps(params))
    resp = trtc_api.StartAIConversation(req)
    return json.loads(resp.to_json_string())     # 包含 TaskId
```

#### handle_farewell_and_stop(body)

一站式结束的核心实现：

```python
def handle_farewell_and_stop(body):
    params = {
        "TaskId": task_id,
        "Command": "ServerPushText",
        "ServerPushText": {
            "Text": farewell_text,           # 告别语
            "Interrupt": True,               # 打断当前播报
            "StopAfterPlay": True,           # TTS 播完后自动停止任务
            "AddHistory": True,
            "Priority": 0,
        },
    }
    req = models.ControlAIConversationRequest()
    req.from_json_string(json.dumps(params))
    resp = trtc_api.ControlAIConversation(req)
```

`StopAfterPlay=True` 的优势：
- 不需要前端估算 TTS 播报时长
- 不需要 setTimeout 轮询
- 服务端确保播报完整后才停止任务

#### handle_transfer_and_stop(body)

与 FarewellAndStop 结构相同，只是文本换成转接提示语。

### 错误处理

```python
class ErrorCode(enum.Enum):
    InvalidParameter = "InvalidParameter"

def err(code: ErrorCode, msg: str):
    return {"Response": {"Error": {"Code": code.name, "Message": msg}}}
```

所有异常统一返回腾讯云 API 风格的错误结构。`TaskNotExist` 错误被静默处理（幂等设计）。

---

## 认证流程

### UserSig 签发

```python
class TRTCSigner:
    def sign_trio(self, user_id):
        # 一次签发 3 套 UserSig：
        # - user_id           → 真人用户
        # - {user_id}_robot   → AI 机器人
        # - {user_id}_avatar  → 数字人
        robot_id = f"{user_id}_robot"
        avatar_id = f"{user_id}_avatar"
        return {
            "user_sig": self.sign(user_id),
            "robot_user_sig": self.sign(robot_id),
            "avatar_user_sig": self.sign(avatar_id),
        }
```

底层使用 `TLSSigAPIv2`（HMAC-SHA256 + zlib 压缩 + Base64URL 编码），这是腾讯云官方库，无需修改。

### 认证流程

1. 前端调用 `join` → 后端用 TRTC SECRET 签发 UserSig
2. UserSig 随响应返回前端 → 前端用于 TRTC SDK 鉴权进房
3. 同时签发机器人的 UserSig → 传递给 `StartAIConversation` API
4. TRTC SECRET 和 CloudAPI 密钥永远不暴露给前端

---

## 核心设计模式

### 1. ServerPushText + StopAfterPlay

这是最重要的设计模式。通过一次 `ControlAIConversation` 调用实现"播报告别语后自动结束"：

```
前端: FarewellAndStop(TaskId, Lang)
  → 后端: ControlAIConversation(ServerPushText + StopAfterPlay=true)
    → TRTC 云端: 打断当前播报 → TTS 播报告别语 → 自动 StopAIConversation
      → 机器人退房
        → 前端: onRobotLeave → 用户退房 → 显示评分
```

### 2. 语音/文字双模混合

同一个对话中可以无缝切换：
- **语音模式**：麦克风 → TRTC 音频流 → 云端 ASR → LLM
- **文字模式**：输入框 → `sendCustomMessage(type: 20000)` → 直接 LLM（跳过 ASR）

### 3. 配置驱动的多语言

一份配置文件 (`env.yaml`) 驱动全链路国际化：
- `LLMConfig.SystemPrompt` / `SystemPromptYue` / `SystemPromptEn`
- `WelcomeMessage.zh/yue/en`
- `EndKeywords.zh/yue/en`
- `FarewellMessage.zh/yue/en`
- `TransferKeywords.zh/yue/en`
- `TransferMessage.zh/yue/en`

后端根据前端传入的 `lang` 参数自动切换。

### 4. 数字人可选降级

```python
def is_avatar_enabled(self):
    avatar = self.env.get("AvatarConfig")
    return bool(avatar.get("Appkey")) and bool(avatar.get("AccessToken")) \
        and bool(avatar.get("VirtualmanProjectId"))
```

三项全填 → 启用数字人，TTSConfig 设为 `dummy`（由数字人引擎处理 TTS）
任一为空 → 降级为纯语音 + `flow` TTS

---

## 依赖清单

```
Flask==3.0.3                     # Web 框架
envyaml==1.10.211231             # YAML 配置加载（支持环境变量）
loguru==0.7.3                    # 结构化日志
tencentcloud-sdk-python==3.1.93  # 腾讯云 OpenAPI SDK（含 TRTC 模块）
```

极简：仅 4 个直接依赖。

---

## 启动与部署

### 开发环境

```bash
# 一键启动（推荐）
./start.sh

# 手动启动
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python app.py
```

`start.sh` 自动完成：
1. 检测 Python >= 3.8
2. 首次从 `env.example.yaml` 生成 `env.yaml` 并暂停提示填密钥
3. 创建 venv 虚拟环境
4. 检测并安装依赖（清华镜像优先）
5. 端口冲突检测
6. 启动 Flask 服务（端口 8080）

### 生产部署建议

| 项目 | 开发 | 生产 |
|------|------|------|
| 服务器 | Flask 内置 | Gunicorn + Nginx |
| 协议 | HTTP | HTTPS（必须，WebRTC 要求） |
| 鉴权 | 无 | `/action` 添加 Token/Session 鉴权 |
| 日志 | loguru 文件 | 接入 ELK / 腾讯云 CLS |
| 配置 | env.yaml | 环境变量或密钥管理服务 |


path: references/config-guide.md
content: # TRTC AI 智能客服 - 配置参数完全指南

## 目录

1. [配置文件结构](#配置文件结构)
2. [部署区域](#部署区域)
3. [腾讯云 API 密钥](#腾讯云-api-密钥)
4. [TRTC 应用凭据](#trtc-应用凭据)
5. [LLM 配置](#llm-配置)
6. [欢迎语](#欢迎语)
7. [数字人配置](#数字人配置)
8. [关键词与告别语](#关键词与告别语)
9. [转人工关键词与提示语](#转人工关键词与提示语)
10. [TTS 音色映射表](#tts-音色映射表)
11. [STT 引擎映射表](#stt-引擎映射表)
12. [固定默认参数](#固定默认参数)
13. [UI 可调参数](#ui-可调参数)
14. [Region 切换机制](#region-切换机制)

---

## 配置文件结构

配置使用 YAML 格式，通过 `envyaml` 库加载（支持环境变量替换）。

```yaml
# env.yaml 完整模板
Deployment:
  Region: intl

CloudAPI:
  SECRET_ID: your-secret-id
  SECRET_KEY: your-secret-key

TRTC:
  SDKAPPID: 0
  [REDACTED_SECRET]

LLMConfig:
  LLMType: openai
  Model: your-model-name
  [REDACTED_SECRET]
  APIUrl: your-llm-api-url
  Timeout: 5.0
  History: 20
  Temperature: 0.3
  SystemPrompt: |
    你是一名专业的客服助手...
  SystemPromptYue: |
    你係一名專業嘅客服助手...
  SystemPromptEn: |
    You are a professional customer service assistant...

WelcomeMessage:
  zh: 您好，我是AI客服小助手...
  yue: 您好，我係AI客服小助手...
  en: "Hello! I'm your AI assistant..."

AvatarConfig:
  AvatarType: tencent
  Appkey: ""
  AccessToken: ""
  VirtualmanProjectId: ""

EndKeywords:
  zh: [拜拜, 再见, 挂了, ...]
  yue: [拜拜, 再見, 收線啦, ...]
  en: [bye, goodbye, see you, ...]

FarewellMessage:
  zh: 感谢您的咨询，再见！
  yue: 多謝您嘅諮詢，再見！
  en: Thank you for your inquiry. Goodbye!

TransferKeywords:
  zh: [转人工, 人工客服, 找人工, ...]
  yue: [轉人工, 人工客服, 搵人工, ...]
  en: [transfer, human agent, real person, ...]

TransferMessage:
  zh: 好的，正在为您转接人工客服，请稍候...
  yue: 好嘅，正在為您轉接人工客服，請稍候...
  en: Sure, transferring you to a human agent, please wait...
```

---

## 部署区域

```yaml
Deployment:
  Region: intl   # intl / cn
```

| 值 | 说明 | TRTC Endpoint | TRTC Region |
|----|------|---------------|-------------|
| `intl` | 国际站账号（默认） | `trtc.intl.tencentcloudapi.com` | `ap-singapore` |
| `cn` | 中国大陆账号 | `trtc.tencentcloudapi.com` | `ap-guangzhou` |

Region 决定了 TRTC OpenAPI 的请求域名和地域参数。

---

## 腾讯云 API 密钥

```yaml
CloudAPI:
  SECRET_ID: your-secret-id
  SECRET_KEY: your-secret-key
```

用于 TRTC OpenAPI 的 TC3-HMAC-SHA256 签名。在腾讯云控制台获取（[国际站](https://console.intl.cloud.tencent.com/cam/capi) | [中国站](https://console.cloud.tencent.com/cam/capi)）。

**安全提醒**：这是主账号密钥，生产环境建议使用子账号并限制权限。

---

## TRTC 应用凭据

```yaml
TRTC:
  SDKAPPID: 0
  [REDACTED_SECRET]
```

- `SDKAPPID`：TRTC 应用 ID，在 TRTC 控制台创建（[国际站](https://console.trtc.io/app) | [中国站](https://console.cloud.tencent.com/trtc/app)）
- `SECRET`：用于生成 UserSig 的密钥

---

## LLM 配置

```yaml
LLMConfig:
  LLMType: openai             # LLM 协议类型（目前仅支持 openai）
  Model: your-model-name      # 模型名称
  [REDACTED_SECRET]        # LLM API Key
  APIUrl: your-llm-api-url    # LLM API 端点（兼容 OpenAI /v1/chat/completions 协议）
  Timeout: 5.0                # 超时时间（秒）
  History: 20                 # 上下文轮数
  Temperature: 0.3            # 温度参数（越低越确定）
  SystemPrompt: |             # 中文系统提示词
    你是一名专业的客服助手...
  SystemPromptYue: |          # 粤语系统提示词（可选，默认用中文）
    你係一名專業嘅客服助手...
  SystemPromptEn: |           # 英文系统提示词（可选，默认用中文）
    You are a professional customer service assistant...
```

**重要**：LLM 由 TRTC 云端 AI Bot 调用，不是本项目后端调用。配置会通过 `StartAIConversation` API 传递给 TRTC 服务端。

> ⚠️ **数据隐私提示**：您的 LLM 配置（包括 APIKey、APIUrl、SystemPrompt）以及用户对话内容将通过 TRTC 云端服务转发给 LLM 提供商。请确保：
> 1. 了解所选 LLM 提供商的数据处理政策
> 2. 不要在 SystemPrompt 中包含敏感的业务数据
> 3. 在生产环境中评估是否需要数据脱敏措施

### 支持的 LLM 提供商

任何兼容 OpenAI 协议的 LLM 均可使用，详见官方 LLM 配置指南（[国际站](https://trtc.io/document/68338?product=conversationalai) | [中国站](https://cloud.tencent.com/document/product/647/115413)）：

**推荐：TokenHub（腾讯云统一 LLM 网关，开箱即用）**

| 配置项 | 国际站 (intl) | 中国站 (cn) |
|--------|-------------|------------|
| LLMType | `openai` | `openai` |
| APIUrl | `https://tokenhub-intl.tencentcloudmaas.com/v1/chat/completions` | `https://tokenhub.tencentmaas.com/v1/chat/completions` |
| Model | `deepseek-v4-flash`（推荐） | `deepseek-v4-flash`（推荐） |
| APIKey | [国际站 TokenHub 控制台](https://console.intl.cloud.tencent.com/tokenhub) 获取 | [中国站 TokenHub 控制台](https://console.cloud.tencent.com/tokenhub) 获取 |

也支持其他兼容 OpenAI 协议的 LLM：
- OpenAI / Azure OpenAI
- DeepSeek
- 通义千问
- 其他支持 `/v1/chat/completions` 的服务

### SystemPrompt 编写建议

1. 明确角色定位和服务范围
2. 限制回复长度（语音场景建议 3 句以内）
3. 要求纯文本输出（不使用 Markdown、括号注释等）
4. 说明何时建议转人工
5. 如有订单等结构化信息，说明如何使用

---

## 欢迎语

```yaml
WelcomeMessage:
  zh: 您好，欢迎来到云尚商城！我是AI客服小助手...
  yue: 您好，歡迎嚟到雲尚商城！我係AI客服小助手...
  en: "Welcome to CloudShop Mall! I'm your AI assistant..."
```

AI Bot 进入房间后自动播报的第一条消息。

---

## 数字人配置

```yaml
AvatarConfig:
  AvatarType: tencent                    # 数字人类型
  Appkey: ""                             # 数字人 Appkey
  AccessToken: ""                        # 数字人 Access Token
  VirtualmanProjectId: ""                # 数字人项目 ID
```

**启用条件**：`Appkey`、`AccessToken`、`VirtualmanProjectId` 三项全部非空。

**降级机制**：任一为空 → 自动降级为纯语音模式。UI 上"数字人"选项会变为灰色不可选。

启用数字人后：
- `TTSConfig.TTSType` 自动设为 `dummy`（TTS 由数字人引擎处理）
- 前端会显示数字人视频流

---

## 关键词与告别语

```yaml
EndKeywords:
  zh: [拜拜, 再见, 先挂了, 先这样, 就这样吧, 没事了, 挂了, 不需要了, 不用了]
  yue: [拜拜, 再見, 收線啦, 咁先啦, 唔使啦, 冇事啦, 掛啦]
  en: [bye, goodbye, see you, that's all, thanks bye, gotta go, i'm done]

FarewellMessage:
  zh: 感谢您光临云尚商城，祝您购物愉快，再见！
  yue: 多謝您光臨雲尚商城，祝您購物愉快，再見！
  en: Thank you for visiting CloudShop Mall. Happy shopping and goodbye!
```

**匹配机制**：前端实时检测用户 ASR 文本，命中关键词后触发 `FarewellAndStop`。

**词边界匹配**：
- 英文：`\b关键词\b`（标准词边界）
- 中文：`(^|[^\u4e00-\u9fa5A-Za-z0-9])关键词(?=[^\u4e00-\u9fa5A-Za-z0-9]|$)`

这样"再见面"不会误触发"再见"，"我没事了"不会误触发"没事了"。

---

## 转人工关键词与提示语

```yaml
TransferKeywords:
  zh: [转人工, 人工客服, 找人工, 转接人工, 真人客服, 找真人]
  yue: [轉人工, 人工客服, 搵人工, 轉接人工, 真人客服]
  en: [transfer, human agent, real person, talk to a person, speak to someone]

TransferMessage:
  zh: 好的，正在为您转接人工客服，请稍候...
  yue: 好嘅，正在為您轉接人工客服，請稍候...
  en: Sure, transferring you to a human agent, please wait...
```

命中转人工关键词后：
1. 调用 `TransferAndStop` 播报转接提示语
2. 前端显示模拟排队进度
3. 排队"失败"后恢复 AI 对话（MVP 中为模拟，生产环境对接真实呼叫中心）

---

## TTS 音色映射表

| 语言 | 性别 | VoiceId | 描述 |
|------|------|---------|------|
| zh（中文） | female | `female-kefu-xiaoyue` | 客服小悦 |
| zh（中文） | male | `male-kefu-xiaoxu` | 客服小徐 |
| yue（粤语） | female | `v-female-k3P8sL0Q` | 粤语女声 |
| yue（粤语） | male | `v-male-L4s7PqZ9` | 粤语男声 |
| en（英文） | female | `v-female-Z3x9LmQ2` | 理性女讲解 |
| en（英文） | male | `v-male-Q6p8ZxL3` | 阳光男演讲 |

TTS 引擎：`flow` 类型 + `flow_01_turbo` 模型（延迟最低）。

> 如需自定义 TTS 音色（更换声线、调整语速语调等），请参考官方 TTS 音色配置指南（[国际站](https://trtc.io/document/79682?product=conversationalai) | [中国站](https://cloud.tencent.com/document/product/647/115414)）。

---

## STT 引擎映射表

| 语言 | STTConfig.Language | 说明 |
|------|-------------------|------|
| zh（中文） | `zh` | 中文识别 |
| yue（粤语） | `zh-TW` | 粤语识别 |
| en（英文） | `en` | 英文识别 |

后端根据前端传入的 `lang` 参数自动选择 STT 引擎。

---

## 固定默认参数

这些参数在后端代码中硬编码，用户无需配置：

| 参数 | 值 | 理由 |
|------|------|------|
| `TurnDetection.SemanticEagerness` | `auto` | 平衡响应速度与耐心 |
| `FilterOneWord` | `false` | 保留"嗯""好"等应答词 |
| `WelcomeMessagePriority` | `0` | 欢迎语可被打断 |
| `MaxIdleTime` | `60s` | 客服标准空闲超时 |
| `SubtitleMode` | `1` | 句子级同步下发（非逐字） |
| `FilterBracketsContent` | `1`(zh) / `2`(en) | 过滤 LLM 输出的舞台说明 |
| `LLMConfig.Streaming` | `true` | 流式输出，首字延迟最优 |

---

## UI 可调参数

用户可通过前端 UI 调整：

| 参数 | 控件 | 取值 | 默认 | 含义 |
|------|------|------|------|------|
| 语言 | 顶部栏切换 | zh/yue/en | 跟随浏览器 | 联动 STT/TTS/Prompt/UI 文案 |
| 对话模式 | Pill 切换 | voice/avatar | voice | 需 AvatarConfig 三项全填 |
| 客服角色 | 头像卡片 | female/male | female | 影响 TTS VoiceId |
| 断句模式 | Pill 切换 | 3(语义)/0(VAD) | 3 | 语义断句用 LLM 判断 |
| 打断模式 | Pill 切换 | 0(智能)/1(手动) | 0 | 智能=自动检测打断 |
| VAD 时长 | 滑块 | 240-2000ms | 600ms | 越小响应越快 |
| 远场人声抑制 | 滑块 | 0-5 | 3 | 越高抑制越强 |
| 打断时长 | 滑块 | 240-2000ms | 600ms | 仅智能打断模式 |

---

## Region 切换机制

`config_loader.py` 中的实现：

```python
REGION_PROFILES = {
    "cn": {
        "trtc_endpoint": "trtc.tencentcloudapi.com",
        "trtc_region": "ap-guangzhou",
    },
    "intl": {
        "trtc_endpoint": "trtc.intl.tencentcloudapi.com",
        "trtc_region": "ap-singapore",
    },
}
```

配置加载时根据 `Deployment.Region` 自动选择对应的 endpoint 和 region。未知 region 值会回退到 `intl`。


path: references/frontend-guide.md
content: # TRTC AI 智能客服 - 前端集成指南

## 目录

1. [TRTC Web SDK 集成](#trtc-web-sdk-集成)
2. [对话生命周期](#对话生命周期)
3. [消息处理协议](#消息处理协议)
4. [文字输入（跳过 ASR）](#文字输入跳过-asr)
5. [关键词检测](#关键词检测)
6. [字幕增量/累积自适应](#字幕增量累积自适应)
7. [转人工流程](#转人工流程)
8. [UI 组件结构](#ui-组件结构)
9. [国际化系统](#国际化系统)
10. [状态管理](#状态管理)

---

## TRTC Web SDK 集成

### 引入 SDK

```html
<script src="https://web.sdk.qcloud.com/trtc/webrtc/v5/dist/trtc.js"></script>
```

### 初始化与进房

```javascript
// 创建 TRTC 客户端
const trtcClient = TRTC.create();

// 绑定事件
trtcClient.on(TRTC.EVENT.CUSTOM_MESSAGE, handleMessage);           // 字幕 + AI 状态
trtcClient.on(TRTC.EVENT.REMOTE_USER_LEAVE, onRobotLeave);         // 机器人退房
trtcClient.on(TRTC.EVENT.REMOTE_VIDEO_AVAILABLE, onVideoAvailable); // 数字人视频流

// 进房
await trtcClient.enterRoom({
    roomId: roomId,        // 房间号（数字）
    scene: 'rtc',
    sdkAppId: sdkAppId,
    userId: userId,
    userSig: userSig,
});

// 开麦克风（纯语音场景）
await trtcClient.startLocalAudio();
```

### 退房

```javascript
await trtcClient.stopLocalAudio();
await trtcClient.exitRoom();
trtcClient.destroy();
```

---

## 对话生命周期

```
idle → connecting → active → ending → idle
                              ↓
                         transferring → idle
```

### 启动流程（startCall）

```javascript
async function startCall() {
    STATE.callState = 'connecting';

    // 1. join：获取 UserSig
    const joinData = await postAction('join', { userid: STATE.userId });
    STATE.sdkAppId = joinData.sdkappid;
    STATE.userSig = joinData.usersig;
    STATE.robotUserId = joinData.robot_userid;
    STATE.robotUserSig = joinData.robot_usersig;
    STATE.endKeywords = joinData.end_keywords;
    STATE.farewellMessage = joinData.farewell_message;
    STATE.transferKeywords = joinData.transfer_keywords;
    STATE.avatarAvailable = joinData.avatar_available;

    // 2. 创建 TRTC 客户端并进房
    trtcClient = TRTC.create();
    bindEvents(trtcClient);
    await trtcClient.enterRoom({ roomId, scene: 'rtc', sdkAppId, userId, userSig });
    await trtcClient.startLocalAudio();

    // 3. StartAIConversation
    const startData = await postAction('StartAIConversation', {
        RoomId: STATE.roomId,
        AgentConfig: {
            UserId: STATE.robotUserId,
            UserSig: STATE.robotUserSig,
            TargetUserId: STATE.userId,
            Lang: USER_CFG.lang,
        },
        UserConfig: {
            InterruptMode: USER_CFG.interruptMode,
            InterruptSpeechDuration: USER_CFG.interruptSpeechDuration,
            VadLevel: USER_CFG.vadLevel,
            VadSilenceTime: USER_CFG.vadSilenceTime,
            Gender: STATE.gender,
        },
        UseAvatar: STATE.useAvatar,
        AvatarConfig: STATE.useAvatar ? {
            AvatarUserID: STATE.avatarUserId,
            AvatarUserSig: STATE.avatarUserSig,
        } : undefined,
    });
    STATE.taskId = startData.TaskId;
    STATE.callState = 'active';
}
```

### 结束流程（farewellAndEnd）

```javascript
async function farewellAndEnd() {
    STATE.callState = 'ending';

    // 1. 发送 FarewellAndStop
    await postAction('FarewellAndStop', {
        TaskId: STATE.taskId,
        Lang: USER_CFG.lang,
    });

    // 2. 等待机器人退房（onRobotLeave 回调）
    // 3. 退房
    await cleanupRoom();

    // 4. 显示评分弹窗
    showRatingOverlay();
}
```

### 机器人退房回调

```javascript
function onRobotLeave(event) {
    const { userId } = event;
    if (userId === STATE.robotUserId || userId === STATE.avatarUserId) {
        if (STATE.callState === 'ending' || STATE.callState === 'transferring') {
            cleanupRoom();
        }
    }
}
```

---

## 消息处理协议

所有云端消息通过 `TRTC.EVENT.CUSTOM_MESSAGE` 事件接收：

```javascript
function handleMessage(event) {
    const { data } = event;
    const msg = JSON.parse(new TextDecoder().decode(data));

    switch (msg.type) {
        case 10000:  // 字幕
            handleSubtitle(msg);
            break;
        case 10001:  // AI 状态
            handleAIState(msg);
            break;
    }
}
```

### type: 10000 - 字幕消息

```json
{
    "type": 10000,
    "sender": "user_xxx_robot",
    "payload": {
        "roundid": "round_123",
        "userid": "user_xxx",       // 发言者
        "text": "你好，请问...",
        "end": false                 // true=该轮结束
    }
}
```

**区分用户和 AI**：
- `payload.userid === STATE.userId` → 用户的 ASR 文本
- `payload.userid !== STATE.userId` → AI 的回复文本

### type: 10001 - AI 状态

```json
{
    "type": 10001,
    "payload": {
        "state": 1,         // 1=聆听 2=思考 3=说话 4=打断 5=结束
        "roundid": "round_123"
    }
}
```

| state | 含义 | 前端响应 |
|-------|------|----------|
| 1 | 聆听 | 显示"聆听中..." |
| 2 | 思考 | 显示 typing 动画 |
| 3 | 说话 | 更新语音状态指示 |
| 4 | 打断 | 标记当前回复被打断 |
| 5 | 结束 | 触发结束流程 |

---

## 文字输入（跳过 ASR）

使用 `type: 20000` 自定义消息协议，文字直接发送到 LLM：

```javascript
function sendTextMessage(text) {
    const message = {
        type: 20000,
        sender: STATE.userId,
        receiver: [STATE.robotUserId],
        payload: {
            id: crypto.randomUUID(),
            message: text,
            timestamp: Date.now(),
        },
    };

    trtcClient.sendCustomMessage({
        cmdId: 2,
        data: new TextEncoder().encode(JSON.stringify(message)).buffer,
    });
}
```

**关键点**：
- `cmdId: 2` 是固定值
- `receiver` 指定机器人 userId
- 文本跳过 ASR 引擎，直接送入 LLM，适合不便说话场景
- 用户发送文字后可无缝切回语音模式

---

## 关键词检测

### 构建正则

```javascript
function buildKeywordRegex(keywords) {
    if (!keywords || keywords.length === 0) return null;

    const patterns = keywords.map(kw => {
        const escaped = kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
        const hasChineseChar = /[\u4e00-\u9fa5]/.test(kw);

        if (hasChineseChar) {
            // 中文：两侧非中文字母数字边界
            return `(^|[^\\u4e00-\\u9fa5A-Za-z0-9])${escaped}(?=[^\\u4e00-\\u9fa5A-Za-z0-9]|$)`;
        } else {
            // 英文：标准词边界
            return `\\b${escaped}\\b`;
        }
    });

    return new RegExp(patterns.join('|'), 'i');
}
```

### 检测触发

```javascript
function detectEndKeyword(text) {
    const lang = USER_CFG.lang;
    const keywords = STATE.endKeywords[lang] || [];
    const regex = buildKeywordRegex(keywords);
    return regex && regex.test(text);
}

// 在用户 ASR 文本结束时检测
if (payload.end && payload.userid === STATE.userId) {
    if (detectEndKeyword(payload.text)) {
        farewellAndEnd();
    } else if (detectTransferKeyword(payload.text)) {
        triggerTransfer();
    }
}
```

---

## 字幕增量/累积自适应

TRTC 下发字幕可能是增量或累积模式，前端自动适配：

```javascript
function processSubtitleText(roundId, newText) {
    const accumulated = STATE.aiRoundText[roundId] || '';

    if (newText.startsWith(accumulated)) {
        // 累积模式：新文本是旧文本的超集
        STATE.aiRoundText[roundId] = newText;
    } else {
        // 增量模式：新文本是新增部分
        STATE.aiRoundText[roundId] = accumulated + newText;
    }

    return STATE.aiRoundText[roundId];
}
```

---

## 转人工流程

```javascript
async function triggerTransfer() {
    STATE.callState = 'transferring';

    // 1. 播报转接提示语
    await postAction('TransferAndStop', {
        TaskId: STATE.taskId,
        Lang: USER_CFG.lang,
    });

    // 2. 等待机器人退房
    // 3. 显示排队进度弹窗
    showTransferOverlay();

    // 4. 模拟排队（MVP）
    await simulateQueueProgress();

    // 5. 排队失败 → 恢复 AI 对话（或对接真实呼叫中心）
    restartAIConversation();
}
```

---

## UI 组件结构

```
┌─────────────────────────────────────────────────┐
│  Top Bar                                          │
│  [☰ 侧边栏] [AI 电商客服]      [🌐 简体中文 ▼]     │
├──────────┬──────────────────────────────────────┤
│          │                                       │
│ Sidebar  │  Main Area                            │
│          │                                       │
│ ┌──────┐ │  ┌────────────────────────────────┐  │
│ │对话模式│ │  │  Agent Select Area              │  │
│ │断句模式│ │  │  [👩 女客服]  [👨 男客服]         │  │
│ │打断模式│ │  │       [开始对话]                 │  │
│ │VAD设置│ │  └────────────────────────────────┘  │
│ │       │ │                                      │
│ └──────┘ │  ┌────────────────────────────────┐  │
│          │  │  Chat Messages                   │  │
│          │  │  [AI] 您好，请问有什么...          │  │
│          │  │          [User] 查询订单          │  │
│          │  │  [AI] 好的，请提供订单号...        │  │
│          │  └────────────────────────────────┘  │
│          │                                      │
│          │  ┌────────────────────────────────┐  │
│          │  │  Input Bar                       │  │
│          │  │  [⌨️/🎤] [📦订单] [🔁转人工] [📞挂断] │  │
│          │  └────────────────────────────────┘  │
├──────────┴──────────────────────────────────────┤
│  Overlays: 评分弹窗 / 订单面板 / 转人工弹窗      │
└─────────────────────────────────────────────────┘
```

### CSS 设计要点

- **CSS 变量系统**：`--primary`、`--bg`、`--surface` 等统一管理主题色
- **响应式**：768px 断点移动端适配，侧边栏可折叠
- **动效**：消息入场 `fadeInUp`、typing dots、评分弹窗滑入
- **无外部依赖**：纯 CSS + 内联 SVG 图标

---

## 国际化系统

### 架构

```javascript
// i18n.js - IIFE 模式
(function() {
    const DICT = {
        zh: { appName: 'AI 电商客服', ... },
        yue: { appName: 'AI 電商客服', ... },
        en: { appName: 'AI Customer Service', ... },
    };

    window.I18N = {
        getLang() { ... },           // 从 localStorage 读取
        setLang(lang) { ... },       // 保存到 localStorage + 广播事件
        t(key, params) { ... },      // 翻译（支持 {param} 模板替换）
    };
})();
```

### 使用

```javascript
// HTML 中
<span data-i18n="appName"></span>

// JS 中
const text = I18N.t('tipConnecting');  // "正在连接..."
const text = I18N.t('duration', { time: '5:30' });  // "通话时长: 5:30"

// 语言切换监听
window.addEventListener('langChange', (e) => {
    const lang = e.detail.lang;
    updateAllTexts();
});
```

### 语言存储

- `localStorage` key: `cs_lang`
- 默认值：跟随浏览器 `navigator.language`
- 切换通过 `CustomEvent('langChange')` 事件广播

---

## 状态管理

前端使用简单的全局对象管理状态：

```javascript
const STATE = {
    // 连接状态
    userId: '',
    roomId: 0,
    sdkAppId: 0,
    userSig: '',
    robotUserId: '',
    robotUserSig: '',
    avatarUserId: '',
    avatarUserSig: '',

    // 功能开关
    avatarAvailable: false,
    useAvatar: false,
    gender: 'female',

    // 通话状态
    callState: 'idle',       // idle | connecting | active | ending | transferring
    taskId: null,
    startTime: null,
    muted: false,
    aiSpeaking: false,

    // 关键词（从 join 响应获取）
    endKeywords: { zh: [], yue: [], en: [] },
    farewellMessage: { zh: '', yue: '', en: '' },
    transferKeywords: { zh: [], yue: [], en: [] },
    transferMessage: { zh: '', yue: '', en: '' },

    // 消息管理
    messages: [],             // 消息气泡列表
    typingMsgId: null,        // AI typing 状态
    aiRoundText: {},          // roundid → 累积文本
    aiRoundLast: {},          // roundid → 上一次文本（去重）
    aiRoundMsgId: {},         // roundid → 消息气泡 ID
};

const USER_CFG = {
    lang: I18N.getLang(),
    mode: 'voice',            // voice | avatar
    inputMode: 'voice',       // voice | text
    interruptMode: 0,         // 0=智能打断 1=手动打断
    interruptSpeechDuration: 600,
    vadLevel: 3,
    vadSilenceTime: 600,
    turnDetectionMode: 3,     // 3=语义断句 0=VAD断句
};
```

### HTTP 请求封装

```javascript
async function postAction(action, body = {}) {
    const resp = await fetch('/action', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'Action': action,
        },
        body: JSON.stringify(body),
    });
    const data = await resp.json();
    if (data.Response?.Error) {
        throw new Error(data.Response.Error.Message);
    }
    return data;
}
```


Security scan verdicts — ClawScan: clean | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (HIGH)