# H5 分享操作手册

> 适用版本：feature/user_20260516 分支
> 适用代码：`src/app/h5/*`、`src/app/api/h5/*`、`src/components/views/h5-view.tsx`、`src/components/h5/h5-client-app.tsx`、`src/lib/h5/*`

本手册讲两件事：**owner 端如何配置一个 H5 分享页**，以及**外部用户如何得到链接并打开**。

---

## 1. 概念速览

| 角色 | 含义 | 在系统中是谁 |
|---|---|---|
| **owner** | 工作区所有者，配置并对外开放 H5 页面 | 已登录的 Forge Web 端用户 |
| **外部用户** | 通过 APP 嵌入的 WebView 访问 Forge 的最终用户 | 由 APP 后端签发 JWT 标识，登录态存在 `forge_h5` cookie |
| **APP** | 对接方（陪伴 APP 等），由开发者提供 `app_id` + `secret` 给 owner 录入 | 在 H5View「对接 APP」清单中 |

数据隔离：同一 H5 页面下的所有外部用户最终写入同一个 owner 的 workspace，但通过 `sessions.ext_user_id`（格式 `{app_id}:{sha256(sub).slice(0,8)}`）做用户级隔离。owner 在桌面端的会话列表里看到这些外部会话时**只能只读查看**（`session.extUserId` 非空触发只读护盾）。

---

## 2. Owner 端：配置一个 H5 分享页

### 2.1 进入入口

1. 登录 Forge Web 端。
2. 切换到目标工作区（顶部 workspace 选择器）。
3. 左侧导航点击 **H5 分享**（图标 `Share2`，i18n key：`sidebar.h5`）。
   对应组件：`src/components/views/h5-view.tsx`，路由视图：`activeView === 'h5'`。

### 2.2 创建页面（每个 workspace 仅一个）

- 首次进入会显示「未启用 H5 分享」空态卡片，点击 **启用** → `POST /api/h5/pages`（body: `{ workspaceId }`）。
- 后端自动生成 `slug`（8 位随机串，URL 唯一），返回完整 `H5Page` 行（含 `id`、`slug`、`enabled=1`、`app_secrets='{}'`、`default_model=''`、`permission_mode='full'`）。

> 约束：`h5_pages.workspace_id` 有 UNIQUE 索引，重复创建会幂等返回现有行（不会报错）。

### 2.3 配置访问地址（slug）

「访问地址」卡片会显示：
```
{origin}/h5/{slug}
```
例如 `https://forge.example.com/h5/k7m2x9`。

操作：
- **复制**：点击复制按钮复制到剪贴板。
- **重新生成**：点击「重新生成」→ 二次确认 → `POST /api/h5/pages/{id}?action=regenerate-slug`。**旧地址立即失效**，外部用户的 cookie 也仍有效到 2h，但 URL 已不可记忆化。

> ⚠️ slug 是地址唯一凭证，泄露后只能吊销并重新分发。重新生成不会回收已签发的 `forge_h5` cookie（cookie 2h 自然过期），如需立即吊销外部身份应「轮换密钥」（见 2.6）。

### 2.4 选择模型

「模型与权限」卡片 → 模型下拉：
- `使用全局默认`：值 `''`，会话创建时回退到 `claude-sonnet-4-6`（见 `h5Router.createSession`）。
- 也可选择任一 `useModels()` 返回的可用模型。

提交：`PATCH /api/h5/pages/{id}`，body `{ defaultModel }`。

### 2.5 选择权限模式

权限下拉：
| 值 | 含义 |
|---|---|
| `full`（推荐默认） | 外部用户全权限运行，等价于 `bypassPermissions:true`，不弹权限确认 |
| `confirm` | 预留值，结构已接 `createPermissionBridge`，但 H5 客户端目前无权限 UI 流 |

提交：`PATCH /api/h5/pages/{id}`，body `{ permissionMode }`。

### 2.6 对接 APP 与密钥管理（核心）

这是 H5 鉴权的钥匙来源——APP 后端必须用 owner 录入的 secret 签 JWT，否则验签必然失败。

**添加 APP**：
1. 在「对接 APP」卡片点击 **添加**。
2. 填写：
   - **名称**：展示名（例如「陪伴 APP」），仅 owner 端显示用。
   - **app_id**：反域名风格全局唯一 ID（例如 `com.peiban.app`），等于 JWT 的 `iss`。
3. 点击 **生成密钥并添加** → `PATCH /api/h5/pages/{id}`，body：
   ```json
   { "appSecretOp": { "type": "add", "appId": "com.peiban.app", "name": "陪伴 APP" } }
   ```
   后端调用 `addApp()`，自动生成一段 secret（`sk_xxx`）并写入 `app_secrets` JSON。

**密钥操作**（每个 APP 行）：
- 👁 显示/隐藏：toggle 明文，默认 `sk_••••xxxx` 掩码。
- 📋 复制：明文复制到剪贴板，交给 APP 后端。
- 🔄 轮换：`PATCH /api/h5/pages/{id}` body `{ "appSecretOp": { "type": "rotate", "appId }`。
  > 轮换后**旧秘密立即失效**，所有用旧 secret 签的 JWT 不再能进入新页面；正在持有 `forge_h5` cookie 的外部用户仍可访问直到 cookie 自然过期（2h）。
- 🗑 移除：从 `app_secrets` 删除该 app_id。

**重命名**（API 支持，UI 未暴露）：`PATCH ...` body `{ "appSecretOp": { "type": "rename", "appId", "name" } }`。

### 2.7 启用/停用 H5 分享

顶部右上角「已启用 / 已停用」按钮：
- 启用 → 停用：需要二次确认（点「已启用」后弹「确认停用？外部用户将被立即拒绝访问」+ 确认/取消），防误触。
- 停用 → 启用：一键直接启用，无需确认（启用是安全操作）。
- `PATCH /api/h5/pages/{id}` body `{ enabled: 0 | 1 }`。
- 停用后：`/h5/{slug}` 入口直接 404，所有 `/api/h5/*` 返回 401（`authenticateH5` 复查 `pageRow.enabled`）。

### 2.8 删除页面（API）

`DELETE /api/h5/pages/{id}` 彻底删除 h5_pages 行，slug 立即不可用，cookie 复查也会查不到页面而 401。

### 2.9 环境变量

`src/lib/h5/cookie.ts::getH5CookieSecret()` 的解析顺序：
1. `FORGE_H5_COOKIE_SECRET`（首选，推荐生产使用）
2. `FORGE_SECRET`（回退）
3. 开发环境硬编码 `'dev-only-h5-cookie-secret-not-for-production'`（`NODE_ENV !== 'production'` 时）
4. 生产环境缺失 → **throw**，启动后任何 H5 API 都会 500

部署生产前务必设置：

```bash
export FORGE_H5_COOKIE_SECRET='<openssl rand -hex 32>'
```

> 此 secret 只用于签 `forge_h5` cookie，与 JWT 验签的 `app_secrets` 是两套密钥。JWT 验签密钥由 owner 在前端逐 APP 录入，不靠环境变量。

---

## 3. APP 后端：签发 JWT

APP 后端拿到 owner 录入的 `(app_id, secret)` 后，在用户进入嵌入页时签一个 HS256 JWT：

### 3.1 JWT 结构

```json
{
  "iss": "com.peiban.app",      // = app_id
  "sub": "u_12345",              // APP 内用户唯一 ID
  "name": "张三",                // 可选，H5 顶栏右上角显示
  "iat": 1717200000,             // 签发时间
  "exp": 1717207200              // 过期，必须 ≤ iat + 2h
}
```

约束（`src/lib/h5/jwt.ts::verifyJwt`）：
- `alg` 必须 `HS256`。
- `iss` 必须在 `app_secrets` 中存在，否则 `unknown_iss` → 401。
- `exp` 必须存在且 ≤ 签发 + 2h，否则 `no_exp`/`expired` → 401。
- `sub` 必须非空。
- 签名用 secret 是 `app_secrets[iss].secret`。

### 3.2 签发样例代码（Node）

参考 `scripts/h5-test.mjs`：
```js
function signJWT(payload, secret) {
  const b64url = (s) => Buffer.from(s).toString('base64')
    .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
  const h = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
  const p = b64url(JSON.stringify(payload))
  const data = `${h}.${p}`
  const sig = crypto.createHmac('sha256', secret).update(data).digest('base64')
    .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
  return `${data}.${sig}`
}
```
（生产可换用 `jsonwebtoken` 库。）

### 3.3 签发后跳转

把 token 拼接到 H5 分享地址后：
```
GET {origin}/h5/{slug}?token={JWT}
```

后端处理（`src/app/h5/[slug]/route.ts`）：
1. 查 `h5_pages WHERE slug=? AND enabled=1` → 不存在 404。
2. `verifyJwt(token, appSecrets)` → 失败返回 `400/401`+错误消息。
3. 合成 `ext_user_id = encodeExtUserId(iss, sub)` = `{iss}:{sha256(sub).slice(0,8)}`。
4. 签发 `forge_h5` cookie（2h，httpOnly, SameSite=Lax, 生产 secure），写入 `Set-Cookie`。
5. 通过一次性 `X-H5-User-Name` response header 传出 `name`（前端首次读取后存入 state 显示在顶栏）。
6. 302 → `/h5/c?slug={slug}`（URL 上的 token 即被擦除）。

---

## 4. 外部用户侧：打开 H5 链接

### 4.1 推荐方式（WebView 内嵌）

APP 在 WebView 里直接 load：
```java
webView.loadUrl("https://forge.example.com/h5/k7m2x9?token=" + jwt);
```

Forge 服务端会自动完成 3.3 节的 cookie 落地与 302。WebView 里 cookie 会自动跟随后续 `/api/h5/*` 请求。

> Iframe 嵌入需注意：`forge_h5` 是 `SameSite=Lax`，顶层导航 OK，跨站 iframe 默认不会带 cookie。如必须用 iframe，否则需要在浏览器设置中给 Forge 域名放行第三方 cookie，或联系作者评估切换 `SameSite=None; Secure` 模式。

### 4.2 直链验证

无 token 也可以打开 `{origin}/h5/{slug}`：
- cookie 仍有效 → 302 到 `/h5/c?slug=xxx`，进入对话页。
- cookie 无效或不存在 → 同样进入 `/h5/c`，但前端 `/api/h5/session/list` 返回 401，**整页渲染「会话已过期，请从 APP 重新进入」**（`src/components/h5/h5-client-app.tsx` 的 `expired` state）。

这种「无 token 静默过期」行为让外部用户在 2h 内刷新页面不会突兀跳回登录；过期后才提示。

### 4.3 本地开发自测

仓库内置脚本 `scripts/h5-test.mjs`：
```bash
# 1. 启动 dev server（另一终端）
pnpm dev    # 或 npm run dev

# 2. 在 Forge 里给某个 workspace 开启 H5 分享，添加 APP 录入 secret
#    得到 slug（例如 k7m2x9）与 secret（例如 sk_abcd1234）

# 3. 生成测试 JWT 并打印 URL
node scripts/h5-test.mjs k7m2x9 com.test.app sk_abcd1234 u_12345 测试用户

# 控制台会输出形如：
# http://localhost:3000/h5/k7m2x9?token=eyJhbGc...
# 同时给出 curl 验证 302 的命令
clone 此 URL 到浏览器即可看到外部用户视角。
```

### 4.4 cookie 有效期与续期

- `forge_h5` cookie TTL = 2 小时（`COOKIE_TTL_SECONDS`）。
- **当前实现不会续期**：cookie 一旦签发就固定 2h 过期，过期后任何 `/api/h5/*` 返回 401，前端渲染过期态。
- 想续期只能让 APP 重新用 JWT 走 `/h5/{slug}?token=xxx` 链路换发新 cookie。

### 4.5 会话行为

- 默认进入「最近活动会话」（无则自动新建）。
- 抽屉菜单（左上角 hamburger）→「我的会话」+ ➕ 新建。
- 新建会话时**旧会话保留**（`POST /api/h5/session/new` 直接 `createSession`，不切换/不接管旧会话）。
- 所有会话标题前缀 `[extuser]`，可在 owner 端只读列表识别。

### 4.6 消息渲染（与桌面端的差异）

- **assistant 文本**：H5 端只渲染 markdown 文本，与桌面端共用 `MarkdownRenderer`（同源代码高亮、表格、diff、列表等样式），由 `/h5/c` 页面经 root layout 的 `globals.css` 共享主题。
- **工具与思考过程不展示**：`tool_use / tool_result / tool_raw_result / tool_progress / thinking / agent_content / permission_request` 等 block 在 H5 端一律忽略，不渲染工具卡或思考面板。这意味着外部用户看不到 AI 中间的工具调用细节，只看到最终文本回复。
- **附件仍渲染**：`image_attachment / file_attachment` 块会渲染为可点缩略图/文件卡片（点击新窗口打开），所以「AI 输出包含文件部分」仍然对用户可见。
- **流式期间**：只累计 `text_delta` 实时渲染 markdown；`tool_use / thinking_*` 等事件被前端忽略；服务端错误会渲染为红色错误气泡；等待首个 token 时显示「正在思考...」小气泡。
- **历史消息**：加载 `/api/h5/messages` 后也是同样规则——assistant `content` 字段是 `JSON.stringify(ContentBlock[])`，前端解析后同上抽取 text 与附件，丢弃其余。

### 4.7 文件上传（外部用户发送附件）

H5 端用户可以像桌面端一样向会话发送文件附件（图片、PDF、文本、代码等），实现走与桌面端完全相同的通道：

1. 点击输入栏左侧的 📎（回形针）按钮 → 触发隐藏的 `<input type="file" multiple>`，由系统/WebView 弹出文件选择器。
2. 选定后逐个 `POST /api/upload`（`multipart/form-data`，无鉴权开放路由，与桌面端共用），后端返回 `{filename, originalName, mimeType, tier, isImage}`。
3. 上传完成前在输入栏上方以 chip 形式显示（图片显示缩略图，其他显示文件图标 + 上传中转圈）。
4. 点 chip 上的 ✕ 可移除未发送的附件。
5. 发送时把 `{ name, filename, mimeType, tier }` 数组随 `POST /api/h5/chat` 的 body 一起发出（字段名 `attachments`），后端 `src/app/api/h5/chat/route.ts` 已支持该字段并把附件 serverPath 注入 prompt。
6. 单文件上限 20 MB（`MAX_FILE_SIZE`，见 `src/app/api/upload/route.ts`）。

**移动端 / 嵌入 APP 兼容性**：

- HTML `<input type="file" multiple>` 是 iOS Safari / Android Chrome / 主流 WebView 都支持的标准控件。
- 但在 APP 内嵌 WebView 中能否弹出系统文件选择器，**取决于宿主 APP 是否实现以下回调**：
  - **Android**：`WebChromeClient.onShowFileChooser(...)`（API 21+ 必需）。若 APP 用的是默认 `WebViewClient` 而没有重写 `WebChromeClient`，回形针按钮点击会无反应。
  - **iOS**：实现 `WKUIDelegate` 的 `webView(_:runOpenPanelWith:...)`（iOS 15+ 简化版）或确认 `WKWebView` 允许文件上传（默认允许）。
- 不要求宿主 APP 实现任何 JS bridge——所有交互都通过标准 HTML file input 完成。集成方只需要确保 WebView 的 `WebChromeClient` / `UIDelegate` 绑定到位即可。

**关于"文件夹上传"**：

- 浏览器标准的 `<input type="file" webkitdirectory>` 可以让桌面端选整个目录，但它在移动端 WebView 上的支持非常不稳定（iOS Safari 不支持，部分 Android 定制 ROM 支持），所以 H5 端**没有使用** `webkitdirectory`。
- 实际"在手机上选文件夹里的多个文件"是通过 `<input type="file" multiple>` multi-select 完成：在 iOS「文件」App 中长按可多选同一目录下的多个文件；Android 各家文件管理器也普遍支持 multi-select。
- 如果业务上确需"选一个目录并上传其下全部文件」，只能在宿主 APP 里实现一个原生 picker 收集后通过 JS bridge 把 FileList 注入 H5，或在 APP 端先在当地打包成 zip 再上传——这是 APP 端的工作，不在 Forge H5 范畴。

---

## 5. 关键约束与安全要点

1. **`ext_user_id` 必须是隔离键**——`h5Router.resolveSession` 用 `sessionRepo.findByIdAndExtUser(sessionId, extUserId, workspaceId)` 校验归属，**绝不能改为 `user_id` 校验**（否则外部用户 A 传入外部用户 B 的 `sessionId` 会通过校验造成跨用户会话泄露，见 `src/lib/h5/router.ts` 注释）。
2. **owner 桌面端对外部会话只读**——`src/app/api/chat/route.ts` 在 `session.extUserId` 时直接返回 403；`chat-view.tsx` 对 `session.extUserId` 渲染只读护盾并禁用输入框。不要在桌面端开「写回外部会话」的口子。
3. **JWT TTL ≤ 2h 强制**——后端 `verifyJwt` 拒绝 `exp - iat > 2h`，避免长 token 被复用。
4. **cookie secret 与 JWT secret 分离**——cookie secret 走环境变量（§2.10），JWT secret 在数据库 `app_secrets` 里。轮换 JWT secret 不影响 cookie；更换 cookie secret 会让所有外部用户立即过期。
5. **slug 是地址唯一凭证**——owner 端重新生成 slug 后旧 URL 即废；越短的 slug 越易被穷举，但当前实现的 8 位随机空间足够。

---

## 6. 故障排查

| 现象 | 可能原因 | 验证 / 修复 |
|---|---|---|
| 打开 `/h5/{slug}?token=...` 返回 401 `unknown_iss` | JWT 的 `iss` 未在 `app_secrets` 中 | 在 H5View 对接 APP 列表确认 `app_id` 与 JWT 的 `iss` 字符串完全一致 |
| 401 `bad_signature` | secret 不匹配 / 签名算法非 HS256 | 复制 owner 端展示的明文 secret 给 APP 后端；确认 `alg="HS256"` |
| 401 `expired` / `no_exp` | `exp` 缺失或早于当前时间 | 检查 APP 时钟与 `exp` 计算（`iat + 2h` 内有效） |
| 打开 `/h5/{slug}` 直接 404 | 页面被停用 / 删除 / slug 重新生成 | owner 端检查「已启用」开关，或核对最新 slug |
| 进入对话页立刻显示「会话已过期」 | cookie 过期 / cookie secret 变过 / 跨域被浏览器拦截 | 重新走 JWT 链路；确认环境变量 `FORGE_H5_COOKIE_SECRET` 未变；检查 iframe 跨站 cookie |
| owner 在桌面端会话列表看不到外部会话 | `listActiveByUser` 默认排除外部会话（`ext_only=false` 隐含行为） | 桌面端 chat 列表不显示外部会话是预期行为；如需排查可调 `GET /api/sessions?ext_only=true` 检查 |
| 在桌面端打开外部会话输入框被禁用 | 只读护盾按设计触发（`session.extUserId` 非空） | 预期行为，不能在桌面端写入 |

---

## 7. 文件清单（修改方向速查）

- 新增：`src/app/h5/`、`src/app/api/h5/`、`src/components/h5/`、`src/components/views/h5-view.tsx`、`src/lib/h5/`、`src/lib/database/repositories/h5-page.repo.ts`、`src/lib/database/schema/sqlite/h5-pages.ts`、`scripts/h5-test.mjs`
- 修改：`src/lib/types.ts`（`Session.extUserId`/`extDisplayName`、`View` 加 `'h5'`）、`src/lib/i18n.ts`（`sidebar.h5`）、`src/lib/database/schema/sqlite/sessions.ts`（两个新列 + 索引）、`src/lib/db.ts`（迁移 + h5_pages 建表）、`src/lib/database/client.ts`（复用同一 sqlite 实例）、`src/lib/database/repositories/session.repo.ts`（`extOnly` 过滤 + H5 查询方法）、`src/app/api/sessions/route.ts`（`ext_only` 参数）、`src/app/api/chat/route.ts`（外部会话桌面端只读）、`src/components/layout/app-layout.tsx`+`left-sidebar.tsx`（导航项）、`src/components/views/chat-view.tsx`（只读护盾）
- 删除：`docs/exec-plans/active/h5-share-design.md`（执行计划文档移入 `done/`）