= ({
onSquareClick(r, c);
};
+ if (webglStatus && !webglStatus.supported) {
+ return (
+
+
+
+ {webglStatus.message}
+
+
+ The Chess app mounted correctly, but this environment failed WebGL initialization, so
+ the Three.js board cannot render here.
+
+ {statusLines.length > 0 && (
+
+ {statusLines.map((line, idx) => (
+
{line}
+ ))}
+
+ )}
+
+
+ );
+ }
+
return (
**常见错误**:Character 向用户发送邮件时,不应将 `folder` 设为 `sent`。虽然这封邮件是 Character "发送"的,但对于用户来说这是一封**收到的邮件**,应设为 `inbox`。
+> **常见错误**:Character 向用户发送邮件时,不应将 `folder` 设为 `sent`。虽然这封邮件是 Character
+> "发送"的,但对于用户来说这是一封**收到的邮件**,应设为 `inbox`。
## 文件夹结构
@@ -34,27 +36,27 @@
#### 邮件文件 `{emailId}.json`
-| 字段 | 类型 | 必填 | 说明 |
-|------|------|------|------|
-| id | string | 是 | 邮件唯一标识,与文件名一致(不含 `.json` 后缀) |
-| from | object | 是 | 发件人信息 |
-| from.name | string | 是 | 发件人姓名 |
-| from.address | string | 是 | 发件人邮箱地址 |
-| to | array | 是 | 收件人列表,每项为 EmailAddress 对象 |
-| cc | array | 是 | 抄送列表,每项为 EmailAddress 对象,可为空数组 |
-| subject | string | 是 | 邮件主题 |
-| content | string | 是 | 邮件正文内容 |
-| timestamp | integer | 是 | 发送/接收时间戳(毫秒) |
-| isRead | boolean | 是 | 是否已读 |
-| isStarred | boolean | 是 | 是否已加星标 |
-| folder | string | 是 | 所属文件夹,可选值:`inbox`、`sent`、`drafts`、`trash` |
+| 字段 | 类型 | 必填 | 说明 |
+| ------------ | ------- | ---- | ------------------------------------------------------ |
+| id | string | 是 | 邮件唯一标识,与文件名一致(不含 `.json` 后缀) |
+| from | object | 是 | 发件人信息 |
+| from.name | string | 是 | 发件人姓名 |
+| from.address | string | 是 | 发件人邮箱地址 |
+| to | array | 是 | 收件人列表,每项为 EmailAddress 对象 |
+| cc | array | 是 | 抄送列表,每项为 EmailAddress 对象,可为空数组 |
+| subject | string | 是 | 邮件主题 |
+| content | string | 是 | 邮件正文内容 |
+| timestamp | integer | 是 | 发送/接收时间戳(毫秒) |
+| isRead | boolean | 是 | 是否已读 |
+| isStarred | boolean | 是 | 是否已加星标 |
+| folder | string | 是 | 所属文件夹,可选值:`inbox`、`sent`、`drafts`、`trash` |
**EmailAddress 对象结构:**
-| 字段 | 类型 | 必填 | 说明 |
-|------|------|------|------|
-| name | string | 是 | 姓名 |
-| address | string | 是 | 邮箱地址 |
+| 字段 | 类型 | 必填 | 说明 |
+| ------- | ------ | ---- | -------- |
+| name | string | 是 | 姓名 |
+| address | string | 是 | 邮箱地址 |
示例:
@@ -85,10 +87,10 @@
存储应用运行时状态,用于启动时恢复现场。前端在状态变更时自动保存并同步到云端。
-| 字段 | 类型 | 必填 | 说明 |
-|------|------|------|------|
-| selectedEmailId | string\|null | 否 | 当前选中的邮件 ID,未选中时为 null |
-| currentFolder | string | 是 | 当前所在文件夹,可选值:`inbox`、`sent`、`drafts`、`trash` |
+| 字段 | 类型 | 必填 | 说明 |
+| --------------- | ------------ | ---- | ---------------------------------------------------------- |
+| selectedEmailId | string\|null | 否 | 当前选中的邮件 ID,未选中时为 null |
+| currentFolder | string | 是 | 当前所在文件夹,可选值:`inbox`、`sent`、`drafts`、`trash` |
示例:
@@ -103,8 +105,7 @@
### Agent 操作(Agent → 前端)
-Agent 负责在云端完成邮件文件的写入/删除,完成后通过下发 Action 通知前端同步刷新。
-前端收到 Action 后仅从云端读取最新数据,不再进行本地文件创建。
+Agent 负责在云端完成邮件文件的写入/删除,完成后通过下发 Action 通知前端同步刷新。前端收到 Action 后仅从云端读取最新数据,不再进行本地文件创建。
**Agent 写邮件**:
diff --git a/apps/webuiapps/src/pages/Email/email_en/guide.md b/apps/webuiapps/src/pages/Email/email_en/guide.md
index 3d4cad4..01e177a 100644
--- a/apps/webuiapps/src/pages/Email/email_en/guide.md
+++ b/apps/webuiapps/src/pages/Email/email_en/guide.md
@@ -2,14 +2,19 @@
## Important: Mailbox Ownership
-This mailbox belongs to the **User**. All emails are viewed from the User's perspective. The `folder` field must be set accordingly:
+This mailbox belongs to the **User**. All emails are viewed from the User's perspective. The
+`folder` field must be set accordingly:
-- **`inbox`**: Emails received by the User. Emails sent by a Character to the User should be placed in `inbox`, because from the User's perspective, it is a received email.
-- **`sent`**: Emails sent by the User. Only emails actively sent by the User should be placed in `sent`.
+- **`inbox`**: Emails received by the User. Emails sent by a Character to the User should be placed
+ in `inbox`, because from the User's perspective, it is a received email.
+- **`sent`**: Emails sent by the User. Only emails actively sent by the User should be placed in
+ `sent`.
- **`drafts`**: The User's drafts.
- **`trash`**: Emails deleted by the User.
-> **Common Mistake**: When a Character sends an email to the User, do NOT set `folder` to `sent`. Although the Character "sent" the email, from the User's perspective it is a **received email** and should be set to `inbox`.
+> **Common Mistake**: When a Character sends an email to the User, do NOT set `folder` to `sent`.
+> Although the Character "sent" the email, from the User's perspective it is a **received email**
+> and should be set to `inbox`.
## Folder Structure
@@ -34,27 +39,27 @@ Stores all email data. Each email is saved as an independent JSON file, named by
#### Email File `{emailId}.json`
-| Field | Type | Required | Description |
-|-------|------|----------|-------------|
-| id | string | Yes | Unique email identifier, matches the filename (without `.json` extension) |
-| from | object | Yes | Sender information |
-| from.name | string | Yes | Sender name |
-| from.address | string | Yes | Sender email address |
-| to | array | Yes | Recipient list, each item is an EmailAddress object |
-| cc | array | Yes | CC list, each item is an EmailAddress object, can be an empty array |
-| subject | string | Yes | Email subject |
-| content | string | Yes | Email body content |
-| timestamp | integer | Yes | Send/receive timestamp (milliseconds) |
-| isRead | boolean | Yes | Whether the email has been read |
-| isStarred | boolean | Yes | Whether the email is starred |
-| folder | string | Yes | Folder name, one of: `inbox`, `sent`, `drafts`, `trash` |
+| Field | Type | Required | Description |
+| ------------ | ------- | -------- | ------------------------------------------------------------------------- |
+| id | string | Yes | Unique email identifier, matches the filename (without `.json` extension) |
+| from | object | Yes | Sender information |
+| from.name | string | Yes | Sender name |
+| from.address | string | Yes | Sender email address |
+| to | array | Yes | Recipient list, each item is an EmailAddress object |
+| cc | array | Yes | CC list, each item is an EmailAddress object, can be an empty array |
+| subject | string | Yes | Email subject |
+| content | string | Yes | Email body content |
+| timestamp | integer | Yes | Send/receive timestamp (milliseconds) |
+| isRead | boolean | Yes | Whether the email has been read |
+| isStarred | boolean | Yes | Whether the email is starred |
+| folder | string | Yes | Folder name, one of: `inbox`, `sent`, `drafts`, `trash` |
**EmailAddress Object Structure:**
-| Field | Type | Required | Description |
-|-------|------|----------|-------------|
-| name | string | Yes | Name |
-| address | string | Yes | Email address |
+| Field | Type | Required | Description |
+| ------- | ------ | -------- | ------------- |
+| name | string | Yes | Name |
+| address | string | Yes | Email address |
Example:
@@ -83,12 +88,13 @@ Example:
### State File `/state.json`
-Stores the app's runtime state for restoring the session on startup. The frontend automatically saves and syncs to the cloud when state changes.
+Stores the app's runtime state for restoring the session on startup. The frontend automatically
+saves and syncs to the cloud when state changes.
-| Field | Type | Required | Description |
-|-------|------|----------|-------------|
-| selectedEmailId | string\|null | No | Currently selected email ID, null when none is selected |
-| currentFolder | string | Yes | Current folder, one of: `inbox`, `sent`, `drafts`, `trash` |
+| Field | Type | Required | Description |
+| --------------- | ------------ | -------- | ---------------------------------------------------------- |
+| selectedEmailId | string\|null | No | Currently selected email ID, null when none is selected |
+| currentFolder | string | Yes | Current folder, one of: `inbox`, `sent`, `drafts`, `trash` |
Example:
@@ -103,13 +109,16 @@ Example:
### Agent Operations (Agent → Frontend)
-The Agent completes email file writes/deletions on the cloud, then dispatches Actions to notify the frontend to sync and refresh.
-After receiving an Action, the frontend only reads the latest data from the cloud — it does not create files locally.
+The Agent completes email file writes/deletions on the cloud, then dispatches Actions to notify the
+frontend to sync and refresh. After receiving an Action, the frontend only reads the latest data
+from the cloud — it does not create files locally.
**Agent Composes an Email**:
-1. The Agent writes the email file `/emails/{id}.json` on the cloud (containing the complete email JSON)
-2. The Agent dispatches the `COMPOSE_EMAIL` Action with `filePath` in params (e.g., `/emails/{id}.json`)
+1. The Agent writes the email file `/emails/{id}.json` on the cloud (containing the complete email
+ JSON)
+2. The Agent dispatches the `COMPOSE_EMAIL` Action with `filePath` in params (e.g.,
+ `/emails/{id}.json`)
3. The frontend reads the file from the cloud, updates the local file tree and UI
**Agent Deletes an Email**:
@@ -126,7 +135,8 @@ After receiving an Action, the frontend only reads the latest data from the clou
### User Operations (Frontend → Cloud)
-User operations on the frontend are in read-only mode — composing, replying, and sending emails are not available. The operations users can perform are:
+User operations on the frontend are in read-only mode — composing, replying, and sending emails are
+not available. The operations users can perform are:
**User Reads an Email**:
diff --git a/apps/webuiapps/src/pages/EvidenceVault/evidencevault_cn/guide.md b/apps/webuiapps/src/pages/EvidenceVault/evidencevault_cn/guide.md
index 74ce404..880be32 100644
--- a/apps/webuiapps/src/pages/EvidenceVault/evidencevault_cn/guide.md
+++ b/apps/webuiapps/src/pages/EvidenceVault/evidencevault_cn/guide.md
@@ -19,22 +19,22 @@
#### 档案文件 `{id}.json`
-| 字段 | 类型 | 必填 | 说明 |
-|------|------|------|------|
-| id | string | 是 | 档案唯一标识 |
-| title | string | 是 | 档案标题 |
-| description | string | 是 | 档案描述摘要 |
-| content | string | 是 | 档案正文内容 |
-| type | string | 是 | 档案类型:`video` / `log` / `transaction` / `document` / `image` / `audio` / `chat` / `email` / `contract` / `report` / `trace` / `relation` |
-| category | string | 是 | 所属分类:`identity` / `family` / `money` / `reputation` / `incident` / `secret` / `other` |
-| impact | string | 是 | 影响类型:`vindicate`(正面)/ `expose`(负面)/ `neutral`(中性)/ `mixed`(复合) |
-| source | string | 是 | 档案来源 |
-| timestamp | number | 是 | 档案时间戳(Unix 毫秒) |
-| credibility | number | 是 | 可信度(0-100) |
-| importance | number | 是 | 重要性(0-100) |
-| tags | string[] | 是 | 标签数组 |
-| vindicateText | string | 否 | 正面影响说明 |
-| exposeText | string | 否 | 负面影响说明 |
+| 字段 | 类型 | 必填 | 说明 |
+| ------------- | -------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------- |
+| id | string | 是 | 档案唯一标识 |
+| title | string | 是 | 档案标题 |
+| description | string | 是 | 档案描述摘要 |
+| content | string | 是 | 档案正文内容 |
+| type | string | 是 | 档案类型:`video` / `log` / `transaction` / `document` / `image` / `audio` / `chat` / `email` / `contract` / `report` / `trace` / `relation` |
+| category | string | 是 | 所属分类:`identity` / `family` / `money` / `reputation` / `incident` / `secret` / `other` |
+| impact | string | 是 | 影响类型:`vindicate`(正面)/ `expose`(负面)/ `neutral`(中性)/ `mixed`(复合) |
+| source | string | 是 | 档案来源 |
+| timestamp | number | 是 | 档案时间戳(Unix 毫秒) |
+| credibility | number | 是 | 可信度(0-100) |
+| importance | number | 是 | 重要性(0-100) |
+| tags | string[] | 是 | 标签数组 |
+| vindicateText | string | 否 | 正面影响说明 |
+| exposeText | string | 否 | 负面影响说明 |
示例:
@@ -78,4 +78,5 @@
### 启动恢复
-前端启动时调用 `initFromCloud()` 从云端拉取 `/files/` 目录下的所有档案文件。若目录为空,则显示空状态。
+前端启动时调用 `initFromCloud()` 从云端拉取 `/files/`
+目录下的所有档案文件。若目录为空,则显示空状态。
diff --git a/apps/webuiapps/src/pages/EvidenceVault/evidencevault_en/guide.md b/apps/webuiapps/src/pages/EvidenceVault/evidencevault_en/guide.md
index 9f03932..bfdc534 100644
--- a/apps/webuiapps/src/pages/EvidenceVault/evidencevault_en/guide.md
+++ b/apps/webuiapps/src/pages/EvidenceVault/evidencevault_en/guide.md
@@ -13,28 +13,29 @@
### Evidence File Collection `/files/`
-Stores all evidence files, each representing a single evidence/archive record. Data is static and read-only, pre-stored on the cloud.
+Stores all evidence files, each representing a single evidence/archive record. Data is static and
+read-only, pre-stored on the cloud.
File naming convention: `{id}.json`, where `id` is a globally unique identifier.
#### Evidence File `{id}.json`
-| Field | Type | Required | Description |
-|-------|------|----------|-------------|
-| id | string | Yes | Unique evidence identifier |
-| title | string | Yes | Evidence title |
-| description | string | Yes | Evidence summary |
-| content | string | Yes | Evidence body content |
-| type | string | Yes | Evidence type: `video` / `log` / `transaction` / `document` / `image` / `audio` / `chat` / `email` / `contract` / `report` / `trace` / `relation` |
-| category | string | Yes | Category: `identity` / `family` / `money` / `reputation` / `incident` / `secret` / `other` |
-| impact | string | Yes | Impact type: `vindicate` (positive) / `expose` (negative) / `neutral` / `mixed` |
-| source | string | Yes | Evidence source |
-| timestamp | number | Yes | Timestamp (Unix milliseconds) |
-| credibility | number | Yes | Credibility score (0-100) |
-| importance | number | Yes | Importance score (0-100) |
-| tags | string[] | Yes | Tag array |
-| vindicateText | string | No | Positive impact description |
-| exposeText | string | No | Negative impact description |
+| Field | Type | Required | Description |
+| ------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
+| id | string | Yes | Unique evidence identifier |
+| title | string | Yes | Evidence title |
+| description | string | Yes | Evidence summary |
+| content | string | Yes | Evidence body content |
+| type | string | Yes | Evidence type: `video` / `log` / `transaction` / `document` / `image` / `audio` / `chat` / `email` / `contract` / `report` / `trace` / `relation` |
+| category | string | Yes | Category: `identity` / `family` / `money` / `reputation` / `incident` / `secret` / `other` |
+| impact | string | Yes | Impact type: `vindicate` (positive) / `expose` (negative) / `neutral` / `mixed` |
+| source | string | Yes | Evidence source |
+| timestamp | number | Yes | Timestamp (Unix milliseconds) |
+| credibility | number | Yes | Credibility score (0-100) |
+| importance | number | Yes | Importance score (0-100) |
+| tags | string[] | Yes | Tag array |
+| vindicateText | string | No | Positive impact description |
+| exposeText | string | No | Negative impact description |
Example:
@@ -78,4 +79,5 @@ Users can only perform the following read-only operations (pure frontend, no clo
### Startup Recovery
-On startup, the frontend calls `initFromCloud()` to pull all evidence files from the `/files/` directory on the cloud. If the directory is empty, an empty state is displayed.
+On startup, the frontend calls `initFromCloud()` to pull all evidence files from the `/files/`
+directory on the cloud. If the directory is empty, an empty state is displayed.
diff --git a/apps/webuiapps/src/pages/FreeCell/freecell_cn/guide.md b/apps/webuiapps/src/pages/FreeCell/freecell_cn/guide.md
index a20e0ab..0f4911d 100644
--- a/apps/webuiapps/src/pages/FreeCell/freecell_cn/guide.md
+++ b/apps/webuiapps/src/pages/FreeCell/freecell_cn/guide.md
@@ -13,21 +13,21 @@
存储当前牌局的完整状态,包括 8 列牌阵、4 个可用单元格、4 个基础牌堆、步数和游戏状态。
-| 字段 | 类型 | 必填 | 说明 |
-|------|------|------|------|
-| columns | Card[][] | 是 | 8 列牌阵,每列为 Card 数组,索引 0-7 |
-| freeCells | (Card \| null)[] | 是 | 4 个可用单元格,null 表示空位 |
-| foundations | Record | 是 | 4 个基础牌堆,按花色分组(hearts/diamonds/clubs/spades),从 A 到 K 顺序堆叠 |
-| moveCount | number | 是 | 当前步数 |
-| gameStatus | string | 是 | 游戏状态:`"playing"` 或 `"won"` |
-| gameId | string | 是 | 牌局唯一标识 |
+| 字段 | 类型 | 必填 | 说明 |
+| ----------- | -------------------- | ---- | ---------------------------------------------------------------------------- |
+| columns | Card[][] | 是 | 8 列牌阵,每列为 Card 数组,索引 0-7 |
+| freeCells | (Card \| null)[] | 是 | 4 个可用单元格,null 表示空位 |
+| foundations | Record | 是 | 4 个基础牌堆,按花色分组(hearts/diamonds/clubs/spades),从 A 到 K 顺序堆叠 |
+| moveCount | number | 是 | 当前步数 |
+| gameStatus | string | 是 | 游戏状态:`"playing"` 或 `"won"` |
+| gameId | string | 是 | 牌局唯一标识 |
#### Card 结构
-| 字段 | 类型 | 必填 | 说明 |
-|------|------|------|------|
-| suit | string | 是 | 花色:`"hearts"` / `"diamonds"` / `"clubs"` / `"spades"` |
-| rank | number | 是 | 点数:1=A, 2-10, 11=J, 12=Q, 13=K |
+| 字段 | 类型 | 必填 | 说明 |
+| ---- | ------ | ---- | -------------------------------------------------------- |
+| suit | string | 是 | 花色:`"hearts"` / `"diamonds"` / `"clubs"` / `"spades"` |
+| rank | number | 是 | 点数:1=A, 2-10, 11=J, 12=Q, 13=K |
示例:
@@ -58,14 +58,19 @@
Agent 可以直接写入 `/state.json` 来设置任意合法牌局状态,然后下发以下 Action 通知前端同步:
-- **SYNC_STATE**:Agent 修改了 state.json(如代替用户走棋),前端收到后执行 `initFromCloud()` 读取最新状态并刷新 UI。
-- **NEW_GAME**:Agent 写入了一局新游戏的 state.json,前端收到后执行 `initFromCloud()` 读取并刷新 UI。
+- **SYNC_STATE**:Agent 修改了 state.json(如代替用户走棋),前端收到后执行 `initFromCloud()`
+ 读取最新状态并刷新 UI。
+- **NEW_GAME**:Agent 写入了一局新游戏的 state.json,前端收到后执行 `initFromCloud()`
+ 读取并刷新 UI。
### 用户操作(前端 -> Agent)
-- **MOVE_CARD**:用户在界面上移动一张或多张牌后,前端更新本地状态并同步到云端,然后上报 `MOVE_CARD` Action(附带 gameId 和 moveCount)。
-- **NEW_GAME**:用户点击"新游戏"按钮后,前端生成新牌局并同步到云端,然后上报 `NEW_GAME` Action(附带 gameId)。
+- **MOVE_CARD**:用户在界面上移动一张或多张牌后,前端更新本地状态并同步到云端,然后上报 `MOVE_CARD`
+ Action(附带 gameId 和 moveCount)。
+- **NEW_GAME**:用户点击"新游戏"按钮后,前端生成新牌局并同步到云端,然后上报 `NEW_GAME`
+ Action(附带 gameId)。
### 启动恢复
-前端启动时调用 `initFromCloud()` 从云端拉取 `/state.json`。若文件不存在,自动生成一局新游戏并写入云端。
+前端启动时调用 `initFromCloud()` 从云端拉取
+`/state.json`。若文件不存在,自动生成一局新游戏并写入云端。
diff --git a/apps/webuiapps/src/pages/FreeCell/freecell_en/guide.md b/apps/webuiapps/src/pages/FreeCell/freecell_en/guide.md
index 9c66ec2..b35f98c 100644
--- a/apps/webuiapps/src/pages/FreeCell/freecell_en/guide.md
+++ b/apps/webuiapps/src/pages/FreeCell/freecell_en/guide.md
@@ -11,23 +11,24 @@
### State File `/state.json`
-Stores the complete state of the current game, including 8 tableau columns, 4 free cells, 4 foundation piles, move count, and game status.
-
-| Field | Type | Required | Description |
-|-------|------|----------|-------------|
-| columns | Card[][] | Yes | 8 tableau columns, each is a Card array, indexed 0-7 |
-| freeCells | (Card \| null)[] | Yes | 4 free cells, null indicates an empty slot |
-| foundations | Record | Yes | 4 foundation piles, grouped by suit (hearts/diamonds/clubs/spades), stacked in order from A to K |
-| moveCount | number | Yes | Current move count |
-| gameStatus | string | Yes | Game status: `"playing"` or `"won"` |
-| gameId | string | Yes | Unique game identifier |
+Stores the complete state of the current game, including 8 tableau columns, 4 free cells, 4
+foundation piles, move count, and game status.
+
+| Field | Type | Required | Description |
+| ----------- | -------------------- | -------- | ------------------------------------------------------------------------------------------------ |
+| columns | Card[][] | Yes | 8 tableau columns, each is a Card array, indexed 0-7 |
+| freeCells | (Card \| null)[] | Yes | 4 free cells, null indicates an empty slot |
+| foundations | Record | Yes | 4 foundation piles, grouped by suit (hearts/diamonds/clubs/spades), stacked in order from A to K |
+| moveCount | number | Yes | Current move count |
+| gameStatus | string | Yes | Game status: `"playing"` or `"won"` |
+| gameId | string | Yes | Unique game identifier |
#### Card Structure
-| Field | Type | Required | Description |
-|-------|------|----------|-------------|
-| suit | string | Yes | Suit: `"hearts"` / `"diamonds"` / `"clubs"` / `"spades"` |
-| rank | number | Yes | Rank: 1=A, 2-10, 11=J, 12=Q, 13=K |
+| Field | Type | Required | Description |
+| ----- | ------ | -------- | -------------------------------------------------------- |
+| suit | string | Yes | Suit: `"hearts"` / `"diamonds"` / `"clubs"` / `"spades"` |
+| rank | number | Yes | Rank: 1=A, 2-10, 11=J, 12=Q, 13=K |
Example:
@@ -56,16 +57,22 @@ Example:
### Agent Operations (Agent → Frontend)
-The Agent can directly write to `/state.json` to set any valid game state, then dispatch the following Actions to notify the frontend to sync:
+The Agent can directly write to `/state.json` to set any valid game state, then dispatch the
+following Actions to notify the frontend to sync:
-- **SYNC_STATE**: The Agent has modified state.json (e.g., making moves on behalf of the user). The frontend executes `initFromCloud()` to read the latest state and refresh the UI.
-- **NEW_GAME**: The Agent has written a new game's state.json. The frontend executes `initFromCloud()` to read and refresh the UI.
+- **SYNC_STATE**: The Agent has modified state.json (e.g., making moves on behalf of the user). The
+ frontend executes `initFromCloud()` to read the latest state and refresh the UI.
+- **NEW_GAME**: The Agent has written a new game's state.json. The frontend executes
+ `initFromCloud()` to read and refresh the UI.
### User Operations (Frontend → Agent)
-- **MOVE_CARD**: After the user moves one or more cards on the board, the frontend updates the local state, syncs to the cloud, and reports the `MOVE_CARD` Action (with gameId and moveCount).
-- **NEW_GAME**: After the user clicks the "New Game" button, the frontend generates a new game and syncs to the cloud, then reports the `NEW_GAME` Action (with gameId).
+- **MOVE_CARD**: After the user moves one or more cards on the board, the frontend updates the local
+ state, syncs to the cloud, and reports the `MOVE_CARD` Action (with gameId and moveCount).
+- **NEW_GAME**: After the user clicks the "New Game" button, the frontend generates a new game and
+ syncs to the cloud, then reports the `NEW_GAME` Action (with gameId).
### Startup Recovery
-On startup, the frontend calls `initFromCloud()` to fetch `/state.json` from the cloud. If the file does not exist, a new game is automatically generated and written to the cloud.
+On startup, the frontend calls `initFromCloud()` to fetch `/state.json` from the cloud. If the file
+does not exist, a new game is automatically generated and written to the cloud.
diff --git a/apps/webuiapps/src/pages/Gomoku/meta/meta_cn/guide.md b/apps/webuiapps/src/pages/Gomoku/meta/meta_cn/guide.md
index 7856f7a..3ef1c50 100644
--- a/apps/webuiapps/src/pages/Gomoku/meta/meta_cn/guide.md
+++ b/apps/webuiapps/src/pages/Gomoku/meta/meta_cn/guide.md
@@ -18,39 +18,39 @@ apps/gomoku/data/
#### 对局记录文件 `{id}.json`
-| 字段 | 类型 | 必填 | 说明 |
-|------|------|------|------|
-| id | string | 是 | 对局唯一标识 |
-| players | array | 是 | 两位玩家信息数组,每项包含 name、color、role |
-| moves | array | 是 | 落子记录数组,按时间顺序排列 |
-| result | object \| null | 否 | 对局结果,对局未结束时为 null |
-| startedAt | number | 是 | 对局开始时间戳(毫秒) |
-| endedAt | number \| null | 是 | 对局结束时间戳(毫秒),未结束时为 null |
+| 字段 | 类型 | 必填 | 说明 |
+| --------- | -------------- | ---- | -------------------------------------------- |
+| id | string | 是 | 对局唯一标识 |
+| players | array | 是 | 两位玩家信息数组,每项包含 name、color、role |
+| moves | array | 是 | 落子记录数组,按时间顺序排列 |
+| result | object \| null | 否 | 对局结果,对局未结束时为 null |
+| startedAt | number | 是 | 对局开始时间戳(毫秒) |
+| endedAt | number \| null | 是 | 对局结束时间戳(毫秒),未结束时为 null |
#### Player 对象
-| 字段 | 类型 | 必填 | 说明 |
-|------|------|------|------|
-| name | string | 是 | 玩家名称,如 "You"、"Agent" |
-| color | string | 是 | 棋子颜色,`"black"` 或 `"white"` |
-| role | string | 是 | 角色类型,`"human"` 或 `"agent"` |
+| 字段 | 类型 | 必填 | 说明 |
+| ----- | ------ | ---- | -------------------------------- |
+| name | string | 是 | 玩家名称,如 "You"、"Agent" |
+| color | string | 是 | 棋子颜色,`"black"` 或 `"white"` |
+| role | string | 是 | 角色类型,`"human"` 或 `"agent"` |
#### Move 对象
-| 字段 | 类型 | 必填 | 说明 |
-|------|------|------|------|
-| position | object | 是 | 落子位置,包含 `row`(0-14)和 `col`(0-14) |
-| color | string | 是 | 落子颜色,`"black"` 或 `"white"` |
-| moveNumber | number | 是 | 第几手(从 1 开始) |
-| timestamp | number | 是 | 落子时间戳(毫秒) |
+| 字段 | 类型 | 必填 | 说明 |
+| ---------- | ------ | ---- | -------------------------------------------- |
+| position | object | 是 | 落子位置,包含 `row`(0-14)和 `col`(0-14) |
+| color | string | 是 | 落子颜色,`"black"` 或 `"white"` |
+| moveNumber | number | 是 | 第几手(从 1 开始) |
+| timestamp | number | 是 | 落子时间戳(毫秒) |
#### Result 对象
-| 字段 | 类型 | 必填 | 说明 |
-|------|------|------|------|
-| winner | string \| null | 是 | 获胜方颜色,平局时为 null |
-| winLine | object \| null | 是 | 五连珠位置信息,认输或平局时为 null |
-| reason | string | 是 | 结束原因:`"five-in-a-row"` \| `"surrender"` \| `"draw"` |
+| 字段 | 类型 | 必填 | 说明 |
+| ------- | -------------- | ---- | -------------------------------------------------------- |
+| winner | string \| null | 是 | 获胜方颜色,平局时为 null |
+| winLine | object \| null | 是 | 五连珠位置信息,认输或平局时为 null |
+| reason | string | 是 | 结束原因:`"five-in-a-row"` \| `"surrender"` \| `"draw"` |
```json
{
@@ -60,15 +60,28 @@ apps/gomoku/data/
{ "name": "Agent", "color": "white", "role": "agent" }
],
"moves": [
- { "position": { "row": 7, "col": 7 }, "color": "black", "moveNumber": 1, "timestamp": 1707350400000 },
- { "position": { "row": 7, "col": 8 }, "color": "white", "moveNumber": 2, "timestamp": 1707350401000 }
+ {
+ "position": { "row": 7, "col": 7 },
+ "color": "black",
+ "moveNumber": 1,
+ "timestamp": 1707350400000
+ },
+ {
+ "position": { "row": 7, "col": 8 },
+ "color": "white",
+ "moveNumber": 2,
+ "timestamp": 1707350401000
+ }
],
"result": {
"winner": "black",
"winLine": {
"positions": [
- { "row": 7, "col": 3 }, { "row": 7, "col": 4 }, { "row": 7, "col": 5 },
- { "row": 7, "col": 6 }, { "row": 7, "col": 7 }
+ { "row": 7, "col": 3 },
+ { "row": 7, "col": 4 },
+ { "row": 7, "col": 5 },
+ { "row": 7, "col": 6 },
+ { "row": 7, "col": 7 }
],
"color": "black"
},
@@ -83,19 +96,19 @@ apps/gomoku/data/
应用统计数据文件,记录历史对局统计信息。
-| 字段 | 类型 | 默认值 | 说明 |
-|------|------|--------|------|
-| currentGameId | string \| null | null | 最近一局的 ID |
-| totalGames | number | 0 | 总对局数 |
-| stats | object | - | 统计数据(见下表) |
+| 字段 | 类型 | 默认值 | 说明 |
+| ------------- | -------------- | ------ | ------------------ |
+| currentGameId | string \| null | null | 最近一局的 ID |
+| totalGames | number | 0 | 总对局数 |
+| stats | object | - | 统计数据(见下表) |
#### Stats 对象
-| 字段 | 类型 | 默认值 | 说明 |
-|------|------|--------|------|
-| blackWins | number | 0 | 黑棋获胜次数 |
-| whiteWins | number | 0 | 白棋获胜次数 |
-| draws | number | 0 | 平局次数 |
+| 字段 | 类型 | 默认值 | 说明 |
+| --------- | ------ | ------ | ------------ |
+| blackWins | number | 0 | 黑棋获胜次数 |
+| whiteWins | number | 0 | 白棋获胜次数 |
+| draws | number | 0 | 平局次数 |
```json
{
diff --git a/apps/webuiapps/src/pages/Gomoku/meta/meta_en/guide.md b/apps/webuiapps/src/pages/Gomoku/meta/meta_en/guide.md
index d5a2db5..dd1eb20 100644
--- a/apps/webuiapps/src/pages/Gomoku/meta/meta_en/guide.md
+++ b/apps/webuiapps/src/pages/Gomoku/meta/meta_en/guide.md
@@ -14,43 +14,44 @@ apps/gomoku/data/
### Game Records `/history/`
-Collection of game history records, one JSON file per game. File name is `{id}.json`, automatically written by the App when a game ends.
+Collection of game history records, one JSON file per game. File name is `{id}.json`, automatically
+written by the App when a game ends.
#### Game Record File `{id}.json`
-| Field | Type | Required | Description |
-|-------|------|----------|-------------|
-| id | string | Yes | Unique game identifier |
-| players | array | Yes | Two-element array of player info, each with name, color, role |
-| moves | array | Yes | Array of move records in chronological order |
-| result | object \| null | No | Game result, null if game is not finished |
-| startedAt | number | Yes | Game start timestamp (milliseconds) |
-| endedAt | number \| null | Yes | Game end timestamp (milliseconds), null if not finished |
+| Field | Type | Required | Description |
+| --------- | -------------- | -------- | ------------------------------------------------------------- |
+| id | string | Yes | Unique game identifier |
+| players | array | Yes | Two-element array of player info, each with name, color, role |
+| moves | array | Yes | Array of move records in chronological order |
+| result | object \| null | No | Game result, null if game is not finished |
+| startedAt | number | Yes | Game start timestamp (milliseconds) |
+| endedAt | number \| null | Yes | Game end timestamp (milliseconds), null if not finished |
#### Player Object
-| Field | Type | Required | Description |
-|-------|------|----------|-------------|
-| name | string | Yes | Player name, e.g. "You", "Agent" |
-| color | string | Yes | Stone color, `"black"` or `"white"` |
-| role | string | Yes | Role type, `"human"` or `"agent"` |
+| Field | Type | Required | Description |
+| ----- | ------ | -------- | ----------------------------------- |
+| name | string | Yes | Player name, e.g. "You", "Agent" |
+| color | string | Yes | Stone color, `"black"` or `"white"` |
+| role | string | Yes | Role type, `"human"` or `"agent"` |
#### Move Object
-| Field | Type | Required | Description |
-|-------|------|----------|-------------|
-| position | object | Yes | Placement position with `row` (0-14) and `col` (0-14) |
-| color | string | Yes | Stone color, `"black"` or `"white"` |
-| moveNumber | number | Yes | Move number (starting from 1) |
-| timestamp | number | Yes | Move timestamp (milliseconds) |
+| Field | Type | Required | Description |
+| ---------- | ------ | -------- | ----------------------------------------------------- |
+| position | object | Yes | Placement position with `row` (0-14) and `col` (0-14) |
+| color | string | Yes | Stone color, `"black"` or `"white"` |
+| moveNumber | number | Yes | Move number (starting from 1) |
+| timestamp | number | Yes | Move timestamp (milliseconds) |
#### Result Object
-| Field | Type | Required | Description |
-|-------|------|----------|-------------|
-| winner | string \| null | Yes | Winning color, null for draws |
-| winLine | object \| null | Yes | Five-in-a-row position info, null for surrender or draw |
-| reason | string | Yes | End reason: `"five-in-a-row"` \| `"surrender"` \| `"draw"` |
+| Field | Type | Required | Description |
+| ------- | -------------- | -------- | ---------------------------------------------------------- |
+| winner | string \| null | Yes | Winning color, null for draws |
+| winLine | object \| null | Yes | Five-in-a-row position info, null for surrender or draw |
+| reason | string | Yes | End reason: `"five-in-a-row"` \| `"surrender"` \| `"draw"` |
```json
{
@@ -60,15 +61,28 @@ Collection of game history records, one JSON file per game. File name is `{id}.j
{ "name": "Agent", "color": "white", "role": "agent" }
],
"moves": [
- { "position": { "row": 7, "col": 7 }, "color": "black", "moveNumber": 1, "timestamp": 1707350400000 },
- { "position": { "row": 7, "col": 8 }, "color": "white", "moveNumber": 2, "timestamp": 1707350401000 }
+ {
+ "position": { "row": 7, "col": 7 },
+ "color": "black",
+ "moveNumber": 1,
+ "timestamp": 1707350400000
+ },
+ {
+ "position": { "row": 7, "col": 8 },
+ "color": "white",
+ "moveNumber": 2,
+ "timestamp": 1707350401000
+ }
],
"result": {
"winner": "black",
"winLine": {
"positions": [
- { "row": 7, "col": 3 }, { "row": 7, "col": 4 }, { "row": 7, "col": 5 },
- { "row": 7, "col": 6 }, { "row": 7, "col": 7 }
+ { "row": 7, "col": 3 },
+ { "row": 7, "col": 4 },
+ { "row": 7, "col": 5 },
+ { "row": 7, "col": 6 },
+ { "row": 7, "col": 7 }
],
"color": "black"
},
@@ -83,19 +97,19 @@ Collection of game history records, one JSON file per game. File name is `{id}.j
Application statistics file, recording historical game statistics.
-| Field | Type | Default | Description |
-|-------|------|---------|-------------|
-| currentGameId | string \| null | null | ID of the most recent game |
-| totalGames | number | 0 | Total number of games played |
-| stats | object | - | Statistics data (see below) |
+| Field | Type | Default | Description |
+| ------------- | -------------- | ------- | ---------------------------- |
+| currentGameId | string \| null | null | ID of the most recent game |
+| totalGames | number | 0 | Total number of games played |
+| stats | object | - | Statistics data (see below) |
#### Stats Object
-| Field | Type | Default | Description |
-|-------|------|---------|-------------|
-| blackWins | number | 0 | Number of black wins |
-| whiteWins | number | 0 | Number of white wins |
-| draws | number | 0 | Number of draws |
+| Field | Type | Default | Description |
+| --------- | ------ | ------- | -------------------- |
+| blackWins | number | 0 | Number of black wins |
+| whiteWins | number | 0 | Number of white wins |
+| draws | number | 0 | Number of draws |
```json
{
@@ -113,7 +127,8 @@ Application statistics file, recording historical game statistics.
### Auto-save on Game End
-1. When a game ends (five-in-a-row, surrender, or draw), App writes the game record to `/history/{id}.json`
+1. When a game ends (five-in-a-row, surrender, or draw), App writes the game record to
+ `/history/{id}.json`
2. App updates statistics in `/state.json`
3. App calls `reportAction` to report the game result
diff --git a/apps/webuiapps/src/pages/MusicApp/meta/meta_cn/guide.md b/apps/webuiapps/src/pages/MusicApp/meta/meta_cn/guide.md
index 42035bf..ad7fecd 100644
--- a/apps/webuiapps/src/pages/MusicApp/meta/meta_cn/guide.md
+++ b/apps/webuiapps/src/pages/MusicApp/meta/meta_cn/guide.md
@@ -21,16 +21,16 @@ apps/musicPlayer/data/
#### 歌曲文件 `{id}.json`
-| Field | Type | Required | Description |
-| ---------- | ------ | -------- | ------------------------------------ |
-| id | string | 是 | 歌曲唯一标识,如 `song-001` |
-| title | string | 是 | 歌曲标题 |
-| artist | string | 是 | 艺术家名称 |
-| album | string | 是 | 专辑名称 |
-| duration | number | 是 | 时长(秒) |
-| coverColor | string | 是 | 封面颜色(hex,如 `#6366F1`) |
-| createdAt | number | 是 | 创建时间戳(毫秒) |
-| audioUrl | string | 是 | 音频文件 URL |
+| Field | Type | Required | Description |
+| ---------- | ------ | -------- | ----------------------------- |
+| id | string | 是 | 歌曲唯一标识,如 `song-001` |
+| title | string | 是 | 歌曲标题 |
+| artist | string | 是 | 艺术家名称 |
+| album | string | 是 | 专辑名称 |
+| duration | number | 是 | 时长(秒) |
+| coverColor | string | 是 | 封面颜色(hex,如 `#6366F1`) |
+| createdAt | number | 是 | 创建时间戳(毫秒) |
+| audioUrl | string | 是 | 音频文件 URL |
```json
{
@@ -51,12 +51,12 @@ apps/musicPlayer/data/
#### 播放列表文件 `{id}.json`
-| Field | Type | Required | Description |
-| --------- | -------- | -------- | ---------------------------------------- |
-| id | string | 是 | 播放列表唯一标识,如 `playlist-001` |
-| name | string | 是 | 播放列表名称 |
-| songIds | string[] | 是 | 歌曲 ID 列表 |
-| createdAt | number | 是 | 创建时间戳(毫秒) |
+| Field | Type | Required | Description |
+| --------- | -------- | -------- | ----------------------------------- |
+| id | string | 是 | 播放列表唯一标识,如 `playlist-001` |
+| name | string | 是 | 播放列表名称 |
+| songIds | string[] | 是 | 歌曲 ID 列表 |
+| createdAt | number | 是 | 创建时间戳(毫秒) |
```json
{
@@ -71,23 +71,23 @@ apps/musicPlayer/data/
应用运行时状态文件,用于现场恢复和跨端状态同步。App 启动时读取此文件恢复上次状态,运行中定期保存。
-| Field | Type | Default | Description |
-| ---------------- | -------------- | ----------- | -------------------------------------------------- |
-| currentView | string | "all-songs" | 当前视图(`"all-songs"` \| `"playlist"`) |
-| activePlaylistId | string \| null | null | 当前选中的播放列表 ID |
-| player | object | - | 播放器状态对象(见下表) |
-| searchQuery | string | "" | 当前搜索关键词 |
+| Field | Type | Default | Description |
+| ---------------- | -------------- | ----------- | ----------------------------------------- |
+| currentView | string | "all-songs" | 当前视图(`"all-songs"` \| `"playlist"`) |
+| activePlaylistId | string \| null | null | 当前选中的播放列表 ID |
+| player | object | - | 播放器状态对象(见下表) |
+| searchQuery | string | "" | 当前搜索关键词 |
#### Player State(嵌套在 `player` 中)
-| Field | Type | Default | Description |
-| ---------------------- | -------------- | ------------ | ------------------------------------------------------------ |
-| currentSongId | string \| null | null | 当前播放歌曲 ID |
-| currentPlaylistContext | string \| null | null | 当前播放上下文的播放列表 ID |
-| isPlaying | boolean | false | 是否正在播放 |
-| currentTime | number | 0 | 当前播放进度(秒) |
-| volume | number | 0.7 | 音量(0.0 - 1.0) |
-| playMode | string | "sequential" | 播放模式(`"sequential"` \| `"repeat-one"` \| `"shuffle"`) |
+| Field | Type | Default | Description |
+| ---------------------- | -------------- | ------------ | ----------------------------------------------------------- |
+| currentSongId | string \| null | null | 当前播放歌曲 ID |
+| currentPlaylistContext | string \| null | null | 当前播放上下文的播放列表 ID |
+| isPlaying | boolean | false | 是否正在播放 |
+| currentTime | number | 0 | 当前播放进度(秒) |
+| volume | number | 0.7 | 音量(0.0 - 1.0) |
+| playMode | string | "sequential" | 播放模式(`"sequential"` \| `"repeat-one"` \| `"shuffle"`) |
```json
{
@@ -118,7 +118,8 @@ apps/musicPlayer/data/
1. 用户在 App 中操作(如创建播放列表、添加歌曲到播放列表)
2. App 将数据写入云端(`syncToCloud`)
-3. App 调用 `reportAction` 上报用户操作(如 `reportAction('CREATE_PLAYLIST', { playlistId: '...' })`)
+3. App 调用 `reportAction` 上报用户操作(如
+ `reportAction('CREATE_PLAYLIST', { playlistId: '...' })`)
### 启动恢复
diff --git a/apps/webuiapps/src/pages/MusicApp/meta/meta_en/guide.md b/apps/webuiapps/src/pages/MusicApp/meta/meta_en/guide.md
index 2a25059..63739c3 100644
--- a/apps/webuiapps/src/pages/MusicApp/meta/meta_en/guide.md
+++ b/apps/webuiapps/src/pages/MusicApp/meta/meta_en/guide.md
@@ -17,20 +17,21 @@ apps/musicPlayer/data/
### Songs `/songs/`
-Song data collection, one JSON file per song. File name is `{id}.json`, written by the App or Agent upon creation.
+Song data collection, one JSON file per song. File name is `{id}.json`, written by the App or Agent
+upon creation.
#### Song File `{id}.json`
-| Field | Type | Required | Description |
-| ---------- | ------ | -------- | ---------------------------------------- |
-| id | string | Yes | Unique song identifier, e.g. `song-001` |
-| title | string | Yes | Song title |
-| artist | string | Yes | Artist name |
-| album | string | Yes | Album name |
-| duration | number | Yes | Duration in seconds |
-| coverColor | string | Yes | Cover color (hex, e.g. `#6366F1`) |
-| createdAt | number | Yes | Creation timestamp (milliseconds) |
-| audioUrl | string | Yes | Audio file URL |
+| Field | Type | Required | Description |
+| ---------- | ------ | -------- | --------------------------------------- |
+| id | string | Yes | Unique song identifier, e.g. `song-001` |
+| title | string | Yes | Song title |
+| artist | string | Yes | Artist name |
+| album | string | Yes | Album name |
+| duration | number | Yes | Duration in seconds |
+| coverColor | string | Yes | Cover color (hex, e.g. `#6366F1`) |
+| createdAt | number | Yes | Creation timestamp (milliseconds) |
+| audioUrl | string | Yes | Audio file URL |
```json
{
@@ -51,12 +52,12 @@ Playlist data collection, one JSON file per playlist. File name is `{id}.json`.
#### Playlist File `{id}.json`
-| Field | Type | Required | Description |
-| --------- | -------- | -------- | -------------------------------------------- |
+| Field | Type | Required | Description |
+| --------- | -------- | -------- | ----------------------------------------------- |
| id | string | Yes | Unique playlist identifier, e.g. `playlist-001` |
-| name | string | Yes | Playlist name |
-| songIds | string[] | Yes | List of song IDs |
-| createdAt | number | Yes | Creation timestamp (milliseconds) |
+| name | string | Yes | Playlist name |
+| songIds | string[] | Yes | List of song IDs |
+| createdAt | number | Yes | Creation timestamp (milliseconds) |
```json
{
@@ -69,23 +70,24 @@ Playlist data collection, one JSON file per playlist. File name is `{id}.json`.
### State File `/state.json`
-Runtime state file for session recovery and cross-device state synchronization. The App reads this file on startup to restore the previous state, and periodically saves during runtime.
+Runtime state file for session recovery and cross-device state synchronization. The App reads this
+file on startup to restore the previous state, and periodically saves during runtime.
-| Field | Type | Default | Description |
-| ---------------- | -------------- | ----------- | -------------------------------------------------- |
-| currentView | string | "all-songs" | Current view (`"all-songs"` \| `"playlist"`) |
-| activePlaylistId | string \| null | null | Currently selected playlist ID |
-| player | object | - | Player state object (see below) |
-| searchQuery | string | "" | Current search keyword |
+| Field | Type | Default | Description |
+| ---------------- | -------------- | ----------- | -------------------------------------------- |
+| currentView | string | "all-songs" | Current view (`"all-songs"` \| `"playlist"`) |
+| activePlaylistId | string \| null | null | Currently selected playlist ID |
+| player | object | - | Player state object (see below) |
+| searchQuery | string | "" | Current search keyword |
#### Player State (nested in `player`)
-| Field | Type | Default | Description |
-| ---------------------- | -------------- | ------------ | ------------------------------------------------------------ |
-| currentSongId | string \| null | null | Currently playing song ID |
-| currentPlaylistContext | string \| null | null | Playlist ID of current playback context |
-| isPlaying | boolean | false | Whether audio is currently playing |
-| currentTime | number | 0 | Current playback position in seconds |
+| Field | Type | Default | Description |
+| ---------------------- | -------------- | ------------ | ----------------------------------------------------------- |
+| currentSongId | string \| null | null | Currently playing song ID |
+| currentPlaylistContext | string \| null | null | Playlist ID of current playback context |
+| isPlaying | boolean | false | Whether audio is currently playing |
+| currentTime | number | 0 | Current playback position in seconds |
| volume | number | 0.7 | Volume level (0.0 - 1.0) |
| playMode | string | "sequential" | Play mode (`"sequential"` \| `"repeat-one"` \| `"shuffle"`) |
@@ -109,21 +111,25 @@ Runtime state file for session recovery and cross-device state synchronization.
### Agent Creates Data
-1. Agent writes song/playlist JSON files to the corresponding NAS directory (`/songs/{id}.json` or `/playlists/{id}.json`)
+1. Agent writes song/playlist JSON files to the corresponding NAS directory (`/songs/{id}.json` or
+ `/playlists/{id}.json`)
2. Agent sends a data mutation Action (e.g. `CREATE_SONG`, `CREATE_PLAYLIST`)
-3. App receives the Action and calls the corresponding Repo's `refresh()` method to pull latest data from cloud
+3. App receives the Action and calls the corresponding Repo's `refresh()` method to pull latest data
+ from cloud
4. UI automatically updates to display new data
### User Creates Data
1. User performs an operation in the App (e.g. create playlist, add song to playlist)
2. App writes data to cloud (`syncToCloud`)
-3. App calls `reportAction` to report the user operation (e.g. `reportAction('CREATE_PLAYLIST', { playlistId: '...' })`)
+3. App calls `reportAction` to report the user operation (e.g.
+ `reportAction('CREATE_PLAYLIST', { playlistId: '...' })`)
### Startup Recovery
1. App starts, reports `DOM_READY` lifecycle event
2. Calls `initFromCloud()` to pull all data from NAS (songs, playlists, state.json)
-3. Reads `state.json`, restores `currentView`, `activePlaylistId`, `player` and other state to memory
+3. Reads `state.json`, restores `currentView`, `activePlaylistId`, `player` and other state to
+ memory
4. UI rendering completes, reports `READY` lifecycle event
5. App enters ready state and begins accepting Agent Actions
diff --git a/apps/webuiapps/src/pages/Twitter/twitter_cn/guide.md b/apps/webuiapps/src/pages/Twitter/twitter_cn/guide.md
index 71fcfdd..20f6b5b 100644
--- a/apps/webuiapps/src/pages/Twitter/twitter_cn/guide.md
+++ b/apps/webuiapps/src/pages/Twitter/twitter_cn/guide.md
@@ -23,42 +23,42 @@
#### 帖子文件 `{postId}.json`
-| 字段 | 类型 | 必填 | 说明 |
-|------|------|------|------|
-| id | string | 是 | 帖子唯一标识,与文件名一致(不含 `.json` 后缀) |
-| author | object | 是 | 作者信息 |
-| author.name | string | 是 | 作者昵称 |
-| author.username | string | 是 | 作者用户名(`@xxx` 格式) |
-| author.avatar | string | 是 | 作者头像 URL,可为空字符串 |
-| content | string | 是 | 帖子内容,最长 280 字符 |
-| timestamp | integer | 是 | 发布时间戳(毫秒) |
-| likes | integer | 是 | 点赞数,最小值为 0 |
-| isLiked | boolean | 是 | 当前用户是否已点赞 |
-| comments | array | 是 | 评论列表,每项为 Comment 对象 |
+| 字段 | 类型 | 必填 | 说明 |
+| --------------- | ------- | ---- | ----------------------------------------------- |
+| id | string | 是 | 帖子唯一标识,与文件名一致(不含 `.json` 后缀) |
+| author | object | 是 | 作者信息 |
+| author.name | string | 是 | 作者昵称 |
+| author.username | string | 是 | 作者用户名(`@xxx` 格式) |
+| author.avatar | string | 是 | 作者头像 URL,可为空字符串 |
+| content | string | 是 | 帖子内容,最长 280 字符 |
+| timestamp | integer | 是 | 发布时间戳(毫秒) |
+| likes | integer | 是 | 点赞数,最小值为 0 |
+| isLiked | boolean | 是 | 当前用户是否已点赞 |
+| comments | array | 是 | 评论列表,每项为 Comment 对象 |
**Comment 对象结构:**
-| 字段 | 类型 | 必填 | 说明 |
-|------|------|------|------|
-| id | string | 是 | 评论唯一标识 |
-| author | object | 是 | 评论作者信息 |
-| author.name | string | 是 | 作者昵称 |
-| author.username | string | 是 | 作者用户名(`@xxx` 格式) |
-| author.avatar | string | 是 | 作者头像 URL,可为空字符串 |
-| content | string | 是 | 评论内容,最长 280 字符 |
-| timestamp | integer | 是 | 评论时间戳(毫秒) |
+| 字段 | 类型 | 必填 | 说明 |
+| --------------- | ------- | ---- | -------------------------- |
+| id | string | 是 | 评论唯一标识 |
+| author | object | 是 | 评论作者信息 |
+| author.name | string | 是 | 作者昵称 |
+| author.username | string | 是 | 作者用户名(`@xxx` 格式) |
+| author.avatar | string | 是 | 作者头像 URL,可为空字符串 |
+| content | string | 是 | 评论内容,最长 280 字符 |
+| timestamp | integer | 是 | 评论时间戳(毫秒) |
### 状态文件 `/state.json`
存储应用运行时状态,用于启动时恢复现场。前端在状态变更时自动保存并同步到云端。
-| 字段 | 类型 | 必填 | 说明 |
-|------|------|------|------|
-| draftContent | string | 否 | 当前发帖输入框中的草稿内容 |
-| currentUser | object | 是 | 当前登录用户信息 |
-| currentUser.name | string | 是 | 用户昵称 |
-| currentUser.username | string | 是 | 用户名(`@xxx` 格式) |
-| currentUser.avatar | string | 是 | 头像 URL,可为空字符串 |
+| 字段 | 类型 | 必填 | 说明 |
+| -------------------- | ------ | ---- | -------------------------- |
+| draftContent | string | 否 | 当前发帖输入框中的草稿内容 |
+| currentUser | object | 是 | 当前登录用户信息 |
+| currentUser.name | string | 是 | 用户昵称 |
+| currentUser.username | string | 是 | 用户名(`@xxx` 格式) |
+| currentUser.avatar | string | 是 | 头像 URL,可为空字符串 |
示例:
@@ -77,8 +77,7 @@
### Agent 操作(Agent → 前端)
-Agent 负责在云端完成文件的写入/修改/删除,完成后通过下发 Action 通知前端同步刷新。
-前端收到 Action 后仅从云端读取最新数据,不再进行本地文件创建。
+Agent 负责在云端完成文件的写入/修改/删除,完成后通过下发 Action 通知前端同步刷新。前端收到 Action 后仅从云端读取最新数据,不再进行本地文件创建。
**Agent 创建帖子**:
@@ -89,7 +88,8 @@ Agent 负责在云端完成文件的写入/修改/删除,完成后通过下发
**Agent 更新/点赞/评论帖子**:
1. Agent 在云端修改帖子文件(更新内容、增加点赞、追加评论等)
-2. Agent 下发对应 Action(`UPDATE_POST`/`LIKE_POST`/`UNLIKE_POST`/`COMMENT_POST`),params 携带 `filePath` 或 `postId`
+2. Agent 下发对应 Action(`UPDATE_POST`/`LIKE_POST`/`UNLIKE_POST`/`COMMENT_POST`),params 携带
+ `filePath` 或 `postId`
3. 前端收到 Action 后从云端重新读取该帖子文件,替换本地数据并刷新 UI
**Agent 删除帖子**:
diff --git a/apps/webuiapps/src/pages/Twitter/twitter_en/guide.md b/apps/webuiapps/src/pages/Twitter/twitter_en/guide.md
index 0ff3dc1..169ca2d 100644
--- a/apps/webuiapps/src/pages/Twitter/twitter_en/guide.md
+++ b/apps/webuiapps/src/pages/Twitter/twitter_en/guide.md
@@ -23,42 +23,43 @@ Stores all post data. Each post is saved as an independent JSON file, named by p
#### Post File `{postId}.json`
-| Field | Type | Required | Description |
-|-------|------|----------|-------------|
-| id | string | Yes | Unique post identifier, matches the filename (without `.json` extension) |
-| author | object | Yes | Author information |
-| author.name | string | Yes | Author display name |
-| author.username | string | Yes | Author username (`@xxx` format) |
-| author.avatar | string | Yes | Author avatar URL, can be an empty string |
-| content | string | Yes | Post content, max 280 characters |
-| timestamp | integer | Yes | Publication timestamp (milliseconds) |
-| likes | integer | Yes | Like count, minimum 0 |
-| isLiked | boolean | Yes | Whether the current user has liked this post |
-| comments | array | Yes | Comment list, each item is a Comment object |
+| Field | Type | Required | Description |
+| --------------- | ------- | -------- | ------------------------------------------------------------------------ |
+| id | string | Yes | Unique post identifier, matches the filename (without `.json` extension) |
+| author | object | Yes | Author information |
+| author.name | string | Yes | Author display name |
+| author.username | string | Yes | Author username (`@xxx` format) |
+| author.avatar | string | Yes | Author avatar URL, can be an empty string |
+| content | string | Yes | Post content, max 280 characters |
+| timestamp | integer | Yes | Publication timestamp (milliseconds) |
+| likes | integer | Yes | Like count, minimum 0 |
+| isLiked | boolean | Yes | Whether the current user has liked this post |
+| comments | array | Yes | Comment list, each item is a Comment object |
**Comment Object Structure:**
-| Field | Type | Required | Description |
-|-------|------|----------|-------------|
-| id | string | Yes | Unique comment identifier |
-| author | object | Yes | Comment author information |
-| author.name | string | Yes | Author display name |
-| author.username | string | Yes | Author username (`@xxx` format) |
-| author.avatar | string | Yes | Author avatar URL, can be an empty string |
-| content | string | Yes | Comment content, max 280 characters |
-| timestamp | integer | Yes | Comment timestamp (milliseconds) |
+| Field | Type | Required | Description |
+| --------------- | ------- | -------- | ----------------------------------------- |
+| id | string | Yes | Unique comment identifier |
+| author | object | Yes | Comment author information |
+| author.name | string | Yes | Author display name |
+| author.username | string | Yes | Author username (`@xxx` format) |
+| author.avatar | string | Yes | Author avatar URL, can be an empty string |
+| content | string | Yes | Comment content, max 280 characters |
+| timestamp | integer | Yes | Comment timestamp (milliseconds) |
### State File `/state.json`
-Stores the app's runtime state for restoring the session on startup. The frontend automatically saves and syncs to the cloud when state changes.
+Stores the app's runtime state for restoring the session on startup. The frontend automatically
+saves and syncs to the cloud when state changes.
-| Field | Type | Required | Description |
-|-------|------|----------|-------------|
-| draftContent | string | No | Current draft content in the post input box |
-| currentUser | object | Yes | Current logged-in user information |
-| currentUser.name | string | Yes | User display name |
-| currentUser.username | string | Yes | Username (`@xxx` format) |
-| currentUser.avatar | string | Yes | Avatar URL, can be an empty string |
+| Field | Type | Required | Description |
+| -------------------- | ------ | -------- | ------------------------------------------- |
+| draftContent | string | No | Current draft content in the post input box |
+| currentUser | object | Yes | Current logged-in user information |
+| currentUser.name | string | Yes | User display name |
+| currentUser.username | string | Yes | Username (`@xxx` format) |
+| currentUser.avatar | string | Yes | Avatar URL, can be an empty string |
Example:
@@ -77,19 +78,23 @@ Example:
### Agent Operations (Agent → Frontend)
-The Agent completes file writes/modifications/deletions on the cloud, then dispatches Actions to notify the frontend to sync and refresh.
-After receiving an Action, the frontend only reads the latest data from the cloud — it does not create files locally.
+The Agent completes file writes/modifications/deletions on the cloud, then dispatches Actions to
+notify the frontend to sync and refresh. After receiving an Action, the frontend only reads the
+latest data from the cloud — it does not create files locally.
**Agent Creates a Post**:
1. The Agent writes the file `/posts/{id}.json` on the cloud (containing the complete post JSON)
-2. The Agent dispatches the `CREATE_POST` Action with `filePath` in params (e.g., `/posts/{id}.json`)
+2. The Agent dispatches the `CREATE_POST` Action with `filePath` in params (e.g.,
+ `/posts/{id}.json`)
3. The frontend reads the file from the cloud, updates the local file tree and UI
**Agent Updates/Likes/Comments on a Post**:
-1. The Agent modifies the post file on the cloud (updates content, adds likes, appends comments, etc.)
-2. The Agent dispatches the corresponding Action (`UPDATE_POST`/`LIKE_POST`/`UNLIKE_POST`/`COMMENT_POST`) with `filePath` or `postId` in params
+1. The Agent modifies the post file on the cloud (updates content, adds likes, appends comments,
+ etc.)
+2. The Agent dispatches the corresponding Action
+ (`UPDATE_POST`/`LIKE_POST`/`UNLIKE_POST`/`COMMENT_POST`) with `filePath` or `postId` in params
3. The frontend re-reads the post file from the cloud, replaces local data, and refreshes the UI
**Agent Deletes a Post**:
@@ -100,7 +105,8 @@ After receiving an Action, the frontend only reads the latest data from the clou
### User Operations (Frontend → Cloud)
-User operations on the frontend are handled by the frontend code. The flow is: local operation → sync to cloud → report Action.
+User operations on the frontend are handled by the frontend code. The flow is: local operation →
+sync to cloud → report Action.
**User Creates a Post**:
diff --git a/apps/webuiapps/vite.config.ts b/apps/webuiapps/vite.config.ts
index d2635e4..6a16753 100644
--- a/apps/webuiapps/vite.config.ts
+++ b/apps/webuiapps/vite.config.ts
@@ -2,7 +2,7 @@ import { UserConfigExport, ConfigEnv, loadEnv } from 'vite';
import type { PluginOption, Plugin } from 'vite';
import legacy from '@vitejs/plugin-legacy';
import react from '@vitejs/plugin-react-swc';
-import { resolve } from 'path';
+import { resolve, sep } from 'path';
import { visualizer } from 'rollup-plugin-visualizer';
import autoprefixer from 'autoprefixer';
import { sentryVitePlugin } from '@sentry/vite-plugin';
@@ -17,6 +17,77 @@ const SESSIONS_DIR = resolve(os.homedir(), '.openroom', 'sessions');
const CHARACTERS_FILE = resolve(os.homedir(), '.openroom', 'characters.json');
const MODS_FILE = resolve(os.homedir(), '.openroom', 'mods.json');
+type ServerConfigSection = Record;
+interface ServerPersistedConfig {
+ llm: ServerConfigSection;
+ imageGen?: ServerConfigSection;
+}
+type ProxyProvider = 'openai' | 'anthropic' | 'minimax' | 'gemini' | 'unknown';
+type ConfigScope = 'llm' | 'imageGen';
+
+let cachedServerConfig: {
+ mtimeMs: number;
+ value: ServerPersistedConfig | null;
+} | null = null;
+
+function isObjectRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+export function normalizeServerConfig(raw: unknown): ServerPersistedConfig | null {
+ if (isObjectRecord(raw) && isObjectRecord(raw.llm)) {
+ const normalized: ServerPersistedConfig = { llm: { ...raw.llm } };
+ if (isObjectRecord(raw.imageGen)) {
+ normalized.imageGen = { ...raw.imageGen };
+ }
+ return normalized;
+ }
+
+ if (isObjectRecord(raw) && 'provider' in raw) {
+ return { llm: { ...raw } };
+ }
+
+ return null;
+}
+
+function getStoredApiKey(section?: ServerConfigSection): string | null {
+ return typeof section?.apiKey === 'string' && section.apiKey.trim() ? section.apiKey : null;
+}
+
+function redactConfigSection(section?: ServerConfigSection): ServerConfigSection | undefined {
+ if (!section) return undefined;
+ return {
+ ...section,
+ apiKey: '',
+ hasApiKey: !!getStoredApiKey(section),
+ };
+}
+
+export function redactServerConfig(config: ServerPersistedConfig | null): Record {
+ if (!config) return {};
+
+ const redacted: Record = {
+ llm: redactConfigSection(config.llm),
+ };
+ const imageGen = redactConfigSection(config.imageGen);
+ if (imageGen) {
+ redacted.imageGen = imageGen;
+ }
+ return redacted;
+}
+
+function mergeConfigSection(
+ existing: ServerConfigSection | undefined,
+ incoming: ServerConfigSection,
+): ServerConfigSection {
+ const merged: ServerConfigSection = { ...(existing || {}), ...incoming };
+ if (!Object.hasOwn(incoming, 'apiKey') && typeof existing?.apiKey === 'string') {
+ merged.apiKey = existing.apiKey;
+ }
+ delete merged.hasApiKey;
+ return merged;
+}
+
/** LLM config persistence plugin — reads/writes config to ~/.openroom/config.json */
function llmConfigPlugin(): Plugin {
return {
@@ -27,14 +98,8 @@ function llmConfigPlugin(): Plugin {
if (req.method === 'GET') {
try {
- if (fs.existsSync(LLM_CONFIG_FILE)) {
- const content = fs.readFileSync(LLM_CONFIG_FILE, 'utf-8');
- res.writeHead(200);
- res.end(content);
- } else {
- res.writeHead(200);
- res.end('{}');
- }
+ res.writeHead(200);
+ res.end(JSON.stringify(redactServerConfig(loadServerConfig())));
} catch (err) {
res.writeHead(500);
res.end(JSON.stringify({ error: String(err) }));
@@ -46,12 +111,52 @@ function llmConfigPlugin(): Plugin {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => {
+ let parsed: unknown;
+ let nextConfig: ServerPersistedConfig | null;
+ let existingConfig: ServerPersistedConfig | null;
+ let mergedConfig: ServerPersistedConfig;
+
try {
const body = Buffer.concat(chunks).toString();
- // Validate JSON before writing
- JSON.parse(body);
- fs.mkdirSync(resolve(os.homedir(), '.openroom'), { recursive: true });
- fs.writeFileSync(LLM_CONFIG_FILE, body, 'utf-8');
+ parsed = JSON.parse(body);
+ nextConfig = normalizeServerConfig(parsed);
+ if (!nextConfig) {
+ throw new Error('Invalid config payload');
+ }
+
+ existingConfig = loadServerConfig();
+ mergedConfig = {
+ llm: mergeConfigSection(existingConfig?.llm, nextConfig.llm),
+ };
+
+ if (isObjectRecord(parsed) && Object.hasOwn(parsed, 'imageGen')) {
+ if (parsed.imageGen !== null) {
+ if (!isObjectRecord(parsed.imageGen)) {
+ throw new Error('Invalid imageGen config payload');
+ }
+ mergedConfig.imageGen = mergeConfigSection(
+ existingConfig?.imageGen,
+ parsed.imageGen,
+ );
+ }
+ } else if (existingConfig?.imageGen) {
+ mergedConfig.imageGen = { ...existingConfig.imageGen };
+ }
+ } catch (err) {
+ res.writeHead(400);
+ res.end(JSON.stringify({ error: String(err) }));
+ return;
+ }
+
+ try {
+ const configDir = resolve(os.homedir(), '.openroom');
+ fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
+ fs.writeFileSync(LLM_CONFIG_FILE, JSON.stringify(mergedConfig), {
+ encoding: 'utf-8',
+ mode: 0o600,
+ });
+ const stat = fs.statSync(LLM_CONFIG_FILE);
+ cachedServerConfig = { mtimeMs: stat.mtimeMs, value: mergedConfig };
res.writeHead(200);
res.end(JSON.stringify({ ok: true }));
} catch (err) {
@@ -69,6 +174,27 @@ function llmConfigPlugin(): Plugin {
};
}
+/**
+ * Sanitize a relative path to prevent directory traversal.
+ * Resolves any ../ sequences and ensures the result stays within the expected base.
+ * Returns null if the path would escape the base directory.
+ */
+function sanitizeRelativePath(relPath: string, baseDir: string): string | null {
+ // Strip null bytes and normalize
+ const cleaned = relPath.replace(/\0/g, '');
+ // Only allow safe characters: alphanumeric, underscore, hyphen, dot, forward slash
+ const safe = cleaned.replace(/[^a-zA-Z0-9_\-./]/g, '_');
+ // Resolve to absolute and verify it stays within baseDir
+ const resolved = resolve(baseDir, safe);
+ // Normalize both paths for comparison (resolve handles .. sequences)
+ const normalizedBase = resolve(baseDir);
+ if (!resolved.startsWith(normalizedBase + sep) && resolved !== normalizedBase) {
+ return null;
+ }
+ // Return the relative portion (stripped of base) for use with join()
+ return resolved.slice(normalizedBase.length + 1) || '';
+}
+
/**
* Session data plugin — reads/writes files under ~/.openroom/sessions/
* API: /api/session-data?path={charId}/{modId}/chat/history.json
@@ -91,8 +217,13 @@ function sessionDataPlugin(): Plugin {
return;
}
- // Sanitize: only allow alphanumeric, underscore, hyphen, dot, forward slash
- const safePath = relPath.replace(/[^a-zA-Z0-9_\-./]/g, '_').replace(/\.\./g, '');
+ // Sanitize: resolve path and ensure it stays within SESSIONS_DIR
+ const safePath = sanitizeRelativePath(relPath, SESSIONS_DIR);
+ if (safePath === null) {
+ res.writeHead(403);
+ res.end(JSON.stringify({ error: 'Invalid path' }));
+ return;
+ }
const filePath = join(SESSIONS_DIR, safePath);
// Directory listing: ?action=list&path=...
@@ -216,7 +347,12 @@ function sessionDataPlugin(): Plugin {
return;
}
- const safePath = relPath.replace(/[^a-zA-Z0-9_\-./]/g, '_').replace(/\.\./g, '');
+ const safePath = sanitizeRelativePath(relPath, SESSIONS_DIR);
+ if (safePath === null) {
+ res.writeHead(403);
+ res.end(JSON.stringify({ error: 'Invalid path' }));
+ return;
+ }
const targetDir = join(SESSIONS_DIR, safePath);
try {
@@ -252,13 +388,180 @@ function logServerPlugin(): Plugin {
};
}
-/** LLM API proxy plugin — resolves browser CORS restrictions */
+/** Load server-side LLM config for API key injection */
+function loadServerConfig(): ServerPersistedConfig | null {
+ try {
+ if (!fs.existsSync(LLM_CONFIG_FILE)) {
+ cachedServerConfig = null;
+ return null;
+ }
+
+ const stat = fs.statSync(LLM_CONFIG_FILE);
+ if (cachedServerConfig && cachedServerConfig.mtimeMs === stat.mtimeMs) {
+ return cachedServerConfig.value;
+ }
+
+ const parsed = normalizeServerConfig(JSON.parse(fs.readFileSync(LLM_CONFIG_FILE, 'utf-8')));
+ cachedServerConfig = { mtimeMs: stat.mtimeMs, value: parsed };
+ return parsed;
+ } catch {
+ // Config file missing or malformed
+ cachedServerConfig = null;
+ }
+ return null;
+}
+
+/** Determine provider type from target URL or X-LLM-Provider hint header */
+export function inferProvider(targetUrl: URL | string, hint?: string): ProxyProvider {
+ const normalizedHint = hint?.trim().toLowerCase();
+ if (normalizedHint) {
+ if (
+ normalizedHint === 'openai' ||
+ normalizedHint === 'anthropic' ||
+ normalizedHint === 'minimax' ||
+ normalizedHint === 'gemini'
+ ) {
+ return normalizedHint;
+ }
+ return 'unknown';
+ }
+
+ try {
+ const parsed = targetUrl instanceof URL ? targetUrl : new URL(targetUrl);
+ const host = parsed.hostname.toLowerCase();
+ if (host.includes('anthropic')) return 'anthropic';
+ if (host.includes('minimax')) return 'minimax';
+ if (host.includes('google') || host.includes('generativelanguage')) return 'gemini';
+ return 'openai'; // default: OpenAI-compatible
+ } catch {
+ return 'unknown';
+ }
+}
+
+export function parseProxyTargetUrl(targetUrl: string): URL | null {
+ try {
+ const parsed = new URL(targetUrl);
+ if (!['https:', 'http:'].includes(parsed.protocol)) {
+ return null;
+ }
+ return parsed;
+ } catch {
+ return null;
+ }
+}
+
+/** Known LLM/image-gen provider hostnames that the proxy may forward to. */
+const ALLOWED_PROVIDER_HOSTS = new Set([
+ 'api.openai.com',
+ 'api.anthropic.com',
+ 'api.deepseek.com',
+ 'api.minimax.io',
+ 'api.z.ai',
+ 'api.moonshot.cn',
+ 'openrouter.ai',
+ 'generativelanguage.googleapis.com',
+]);
+
+/** Local development hosts — only allowed when ALLOW_LOCAL_LLM=true. */
+const LOCAL_LLM_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1']);
+
+/** Validate that a parsed target URL points to an allowed provider host.
+ * Public provider hosts must use HTTPS. Local development hosts may use HTTP/HTTPS
+ * only when ALLOW_LOCAL_LLM=true. */
+export function isAllowedTarget(parsed: URL): boolean {
+ const host = parsed.hostname.toLowerCase();
+ if (ALLOWED_PROVIDER_HOSTS.has(host)) {
+ return parsed.protocol === 'https:';
+ }
+ if (LOCAL_LLM_HOSTS.has(host) && process.env.ALLOW_LOCAL_LLM === 'true') {
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:';
+ }
+ return false;
+}
+
+function normalizeConfigScope(scope?: string): ConfigScope | undefined {
+ return scope === 'imageGen' || scope === 'llm' ? scope : undefined;
+}
+
+export function selectServerApiKey(
+ config: ServerPersistedConfig | null,
+ scope: ConfigScope | undefined,
+ provider: ProxyProvider,
+): string | null {
+ if (!config) return null;
+
+ const llmApiKey = getStoredApiKey(config.llm);
+ const imageGenApiKey = getStoredApiKey(config.imageGen);
+
+ if (scope === 'llm') return llmApiKey;
+ if (scope === 'imageGen') return imageGenApiKey;
+ if (provider === 'gemini') return imageGenApiKey || llmApiKey;
+ return llmApiKey || imageGenApiKey;
+}
+
+function isBlockedPassthroughHeader(headerName: string): boolean {
+ return (
+ headerName.startsWith('x-llm-') ||
+ [
+ 'authorization',
+ 'cookie',
+ 'host',
+ 'connection',
+ 'proxy-authorization',
+ 'x-api-key',
+ 'x-goog-api-key',
+ // Hop-by-hop headers per RFC 9110 — forwarding these can cause
+ // request smuggling or broken upstream responses
+ 'content-length',
+ 'transfer-encoding',
+ 'content-encoding',
+ 'accept-encoding',
+ 'te',
+ 'trailer',
+ 'upgrade',
+ 'keep-alive',
+ ].includes(headerName)
+ );
+}
+
+/** Inject API key from server-side config into proxy request headers */
+function injectServerApiKey(
+ headers: Record,
+ targetUrl: URL,
+ providerHint?: string,
+ configScope?: string,
+): void {
+ const config = loadServerConfig();
+ const provider = inferProvider(targetUrl, providerHint);
+ const apiKey = selectServerApiKey(config, normalizeConfigScope(configScope), provider);
+
+ if (!apiKey) return;
+
+ if (provider === 'anthropic' || provider === 'minimax') {
+ headers['x-api-key'] = apiKey;
+ } else if (provider === 'gemini') {
+ headers['x-goog-api-key'] = apiKey;
+ } else {
+ headers['authorization'] = `Bearer ${apiKey}`;
+ }
+}
+
+/** LLM API proxy plugin — resolves browser CORS restrictions
+ * API keys are now injected server-side from ~/.openroom/config.json.
+ * The browser never sends or sees API keys. */
function llmProxyPlugin(): Plugin {
return {
name: 'llm-proxy',
configureServer(server) {
server.middlewares.use('/api/llm-proxy', async (req, res) => {
- const targetUrl = req.headers['x-llm-target-url'] as string;
+ // Normalize header values — Node http headers can be string[] for duplicates
+ const getHeader = (name: string): string | undefined => {
+ const val = req.headers[name];
+ if (Array.isArray(val)) return val[0];
+ return val;
+ };
+
+ const targetUrl = getHeader('x-llm-target-url');
if (!targetUrl) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing X-LLM-Target-URL header' }));
@@ -269,24 +572,78 @@ function llmProxyPlugin(): Plugin {
req.on('end', async () => {
try {
const body = Buffer.concat(chunks).toString();
+ const parsedTargetUrl = parseProxyTargetUrl(targetUrl);
+ if (!parsedTargetUrl) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Invalid target URL' }));
+ return;
+ }
+
+ // Strict SSRF: validate target host BEFORE injecting keys
+ if (!isAllowedTarget(parsedTargetUrl)) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(
+ JSON.stringify({
+ error: 'Target host not allowed',
+ host: parsedTargetUrl.hostname,
+ }),
+ );
+ return;
+ }
const headers: Record = {};
- // Forward all headers except host/connection/internal ones
- const skipKeys = new Set(['host', 'connection', 'content-length', 'x-llm-target-url']);
+ // Only forward safe, non-sensitive headers from the browser.
+ // API keys are NO LONGER accepted from the client — they come from server config.
+ const allowKeys = new Set([
+ 'content-type',
+ 'anthropic-version', // Anthropic API version (not a secret)
+ ]);
for (const [key, val] of Object.entries(req.headers)) {
if (typeof val !== 'string') continue;
- if (skipKeys.has(key)) continue;
- if (key.startsWith('x-custom-')) {
- headers[key.replace('x-custom-', '')] = val;
- } else {
+ if (allowKeys.has(key)) {
headers[key] = val;
+ } else if (key.startsWith('x-custom-')) {
+ // Only forward x-custom- headers that map to safe, non-sensitive names
+ const strippedKey = key.slice('x-custom-'.length);
+ // Block headers that could inject auth or override internal routing
+ if (strippedKey && !isBlockedPassthroughHeader(strippedKey)) {
+ headers[strippedKey] = val;
+ }
}
+ // All other headers (including authorization and x-api-key) are dropped
}
- const fetchRes = await fetch(targetUrl, {
- method: req.method || 'POST',
+ // Inject API key from server-side config
+ injectServerApiKey(
headers,
- body,
- });
+ parsedTargetUrl,
+ getHeader('x-llm-provider'),
+ getHeader('x-llm-config-scope'),
+ );
+
+ const controller = new AbortController();
+ const timeoutMs = 90_000;
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
+
+ let fetchRes: Response;
+ try {
+ fetchRes = await fetch(parsedTargetUrl.toString(), {
+ method: req.method || 'POST',
+ headers,
+ body,
+ signal: controller.signal,
+ });
+ } catch (err) {
+ if (err instanceof Error && err.name === 'AbortError') {
+ res.writeHead(504, { 'Content-Type': 'application/json' });
+ res.end(
+ JSON.stringify({ error: `Upstream request timed out after ${timeoutMs}ms` }),
+ );
+ return;
+ }
+ throw err;
+ } finally {
+ clearTimeout(timeoutId);
+ }
res.writeHead(fetchRes.status, {
'Content-Type': fetchRes.headers.get('Content-Type') || 'application/json',
diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts
index 059806c..f2ae429 100644
--- a/e2e/app.spec.ts
+++ b/e2e/app.spec.ts
@@ -101,19 +101,28 @@ test.describe('Chat panel – input interaction', () => {
await expect(sendBtn).toBeDisabled();
});
- test('typing a message and clicking send adds it to the messages area', async ({ page }) => {
+ test('typing a message without LLM config opens settings instead of sending', async ({
+ page,
+ }) => {
+ // Stub the config API so the app sees no LLM config (unconfigured state)
+ await page.route('**/api/llm-config', async (route) => {
+ await route.fulfill({
+ contentType: 'application/json',
+ body: '{}',
+ }); // Empty JSON object → no config
+ });
await page.goto('/');
const input = page.locator('[data-testid="chat-input"]');
const sendBtn = page.locator('[data-testid="send-btn"]');
const messages = page.locator('[data-testid="chat-messages"]');
+ const settingsModal = page.locator('[data-testid="settings-modal"]');
await input.fill('Test message from E2E');
await sendBtn.click();
- // The user message should appear in the messages area
- await expect(messages).toContainText('Test message from E2E');
- // Input should be cleared after sending
- await expect(input).toHaveValue('');
+ await expect(settingsModal).toBeVisible();
+ await expect(messages).not.toContainText('Test message from E2E');
+ await expect(input).toHaveValue('Test message from E2E');
});
});