Compare commits

...

6 Commits

Author SHA1 Message Date
hypercross 1d2b4b3e1e feat: layer docs 2026-03-18 14:28:44 +08:00
hypercross 301f499494 feat: resources? 2026-03-18 13:25:01 +08:00
hypercross 8213092bb6 fix: docs 2026-03-18 13:02:50 +08:00
hypercross 4f9d295bd5 feat: mcp prompts 2026-03-18 12:13:15 +08:00
hypercross 62aff91a86 fix: mcp 2026-03-18 12:08:28 +08:00
hypercross eecf429a20 fix: env and __CONTENT_INDEX 2026-03-18 11:31:13 +08:00
15 changed files with 2741 additions and 191 deletions

432
docs/csv.md Normal file
View File

@ -0,0 +1,432 @@
# CSV 编写说明
TTRPG Tools 使用带有 YAML Front Matter 的 CSV 格式来定义卡牌/表格数据。这种格式支持在 CSV 文件头部定义元数据、字段配置和共享属性。
## 文件结构
```csv
---
# YAML Front Matter可选
fields:
- name: 字段名
description: 字段描述
deck:
size: 54x86
grid: 5x8
shared_prop: 共享属性值
---
# CSV 数据
label,body,field1,field2
1内容值 1值 2
```
---
## YAML Front Matter
Front Matter 是可选的,位于文件开头,使用 `---` 包裹的 YAML 格式内容。
### 基本结构
```yaml
---
fields:
- name: name
description: 卡牌名称
- name: effect
description: 效果描述
examples: ["造成 5d6 伤害", "恢复 2d4 生命值"]
deck:
size: 63x88
grid: 5x5
bleed: 1
padding: 2
custom_prop: 自定义属性
---
```
### Front Matter 属性
#### `fields` - 字段定义
定义 CSV 中各列的含义和配置。
```yaml
fields:
- name: name # 字段名称(英文,用于 CSV 列名)
description: 卡牌名称 # 字段描述
examples: # 示例值列表(可选)
- 火球术卷轴
- 治疗药水
dataType: word # 数据类型可选word/paragraph/number/symbol/symbol_list
function: identify # 字段功能可选identify/compare/flavor/rule
```
**数据类型说明:**
| 类型 | 说明 | 示例 |
|------|------|------|
| `word` | 词语 - 短文本 | "火球术"、"战士"、"传说" |
| `paragraph` | 文本段落 - 长描述 | "造成 5d6 点火焰伤害,范围内的所有生物进行敏捷豁免" |
| `number` | 数字 - 可比较的数值 | 3、5、10 |
| `symbol` | 单一符号 - 图标/标记 | 🔥、⚔️、🛡️ |
| `symbol_list` | 符号列表 - 多个图标/标记 | 🔥🔥🔥、⚔️🛡️💫 |
**字段功能说明:**
| 功能 | 说明 | 示例 |
|------|------|------|
| `identify` | 辨识 - 用于识别卡牌身份 | 名称、编号、唯一标识 |
| `compare` | 比较 - 用于游戏机制中的数值比较 | 费用、攻击力、防御力 |
| `flavor` | 游戏风味描述 - 提供背景故事/氛围 | 背景故事、引言、lore |
| `rule` | 规则确认 - 明确游戏规则相关的信息 | 效果文本、触发条件、限制 |
#### `deck` - 卡牌显示配置
用于 `:md-deck` 组件的默认配置。
```yaml
deck:
size: 63x88 # 卡牌尺寸mm格式 "宽 x 高"
grid: 5x5 # 网格布局,格式 "列 x 行"
bleed: 1 # 出血边距mm
padding: 2 # 内边距mm
shape: rectangle # 卡牌形状rectangle/circle/hex/diamond
layers: "name:1-2,14" # 图层配置(可选)
back_layers: "" # 背面图层配置(可选)
```
图层配置语法遵循`layer:1,1-5,8f8s`格式:
- `layer`:图层名称,多个图层用空格分隔
- `1,1-5,8`:网格范围,覆盖卡牌排版网格中的指定区域。
- `f8`字体大小使用8mm字体。可选。
- `s`图层朝向有nwse东西南北四种方向描述文字上侧的朝向默认为n北方。
#### 自定义属性
可以在 Front Matter 中定义任意自定义属性,这些属性会被自动应用到所有卡牌行。
```yaml
---
game_system: 龙与地下城 5e
author: DM_Name
version: 1.0
---
```
---
## CSV 数据部分
### 基本格式
```csv
label,body,field1,field2
1内容值 1值 2
```
### 必需列
#### `label`
每行的唯一标识,用于查找和修改卡牌。
- 可以是数字:`1`, `2`, `3`
- 可以是文本:`card_001`, `npc_villager`
- 支持分组前缀:`forest_1`, `dungeon_1`
#### `body`
卡牌的主要内容,支持 Markdown 格式。
```csv
label,body
1,"### 标题
**加粗文本**
- 列表项 1
- 列表项 2"
```
### 可选列
`label``body` 外,可以定义任意自定义列,列名在 Front Matter 的 `fields` 中定义。
```csv
label,body,name,type,cost,effect
1完整内容火球术法术3造成 5d6 火焰伤害
```
---
## 变量语法
`body` 列或 Front Matter 中,可以使用 `{{prop}}` 语法引用其他列的值或 Front Matter 中的属性。
### 引用同行其他列
```csv
label,body,adj,noun
1,"**{{adj}}** 的{{noun}}",高大,战士
2,"{{adj}}的{{noun}}",矮小,法师
```
渲染后:
- 第 1 行:**高大** 的战士
- 第 2 行:矮小的法师
### 引用 Front Matter 属性
```yaml
---
game_system: D&D 5e
author: DM_Name
---
```
```csv
label,body
1,"这是一个{{game_system}}冒险,由{{author}}创建"
```
渲染后:这是一个 D&D 5e 冒险,由 DM_Name 创建
### 随机引用remix 模式)
`:md-table` 组件中,可以使用 `remix` 属性启用随机引用:
```markdown
:md-table[./data.csv]{remix=true}
```
此时 `{{prop}}` 会从所有行中随机选择一行的值,而不是使用当前行的值。
---
## 分组支持
可以使用额外的列来对数据进行分组。
```csv
group,label,body
森林小径1狼群袭击
森林小径2荆棘陷阱
森林小径3迷路
塔楼楼梯1台阶坍塌
塔楼楼梯2落石
```
`:md-table` 组件中,可以使用 `group` 属性来筛选显示特定组的数据:
```markdown
:md-table[./encounters.csv]{group="森林小径"}
```
---
## 特殊字符处理
### 包含逗号的值
使用双引号包裹:
```csv
label,body
1,"Hello, world"
```
### 包含双引号的值
使用两个双引号转义:
```csv
label,body
1,"他说:""你好"""
```
### 包含换行的值
使用双引号包裹,直接写入换行:
```csv
label,body
1,"### 标题
这是第一段
这是第二段"
```
### 包含 `#` 注释
CSV 解析器会将 `#` 开头的行视为注释,但如果 `#` 在双引号内则正常处理:
```csv
label,body
1,"# 这不是注释"
# 这是真正的注释
```
---
## 完整示例
### 示例 1魔法物品卡牌组
```csv
---
fields:
- name: name
description: 物品名称
dataType: word
function: identify
- name: type
description: 物品类型
dataType: word
function: identify
examples: [武器,防具,饰品,消耗品]
- name: rarity
description: 稀有度
dataType: word
function: compare
examples: [普通,稀有,史诗,传说]
- name: cost
description: 价格
dataType: number
function: compare
- name: effect
description: 效果描述
dataType: paragraph
function: rule
deck:
size: 63x88
grid: 5x5
shape: rectangle
game_system: D&D 5e
---
label,body,name,type,rarity,cost,effect
1,"### 火球术卷轴
一张泛黄的羊皮纸,上面绘有火焰符文。",火球术卷轴消耗品稀有150,"投掷后对 20 尺内所有生物造成 8d6 火焰伤害(敏捷豁免减半)"
2,"### 治疗药水
红色液体在玻璃瓶中翻滚,散发着温暖的光芒。",治疗药水消耗品普通50,"饮用后恢复 2d4+2 点生命值"
3,"### +1 长剑
剑刃闪烁着微光,握柄包裹着精致的皮革。",+1 长剑武器稀有300,"攻击和伤害检定 +1"
```
### 示例 2随机遭遇表
```csv
---
fields:
- name: group
description: 遭遇地点分组
- name: title
description: 遭遇标题
- name: type
description: 遭遇类型
deck:
size: 54x86
grid: 4x6
---
group,label,body,title,type
森林小径1,"### 狼群袭击
三只灰狼从灌木丛中扑出。
**交互**战斗3 只巨狼)或逃脱(敏捷检定 3/2
**风险**:失败受到 d8 撕咬伤害",狼群袭击,战斗
森林小径2,"### 荆棘陷阱
带刺藤蔓缠住你的双腿。
**交互**:挣脱(力量检定 3/2
**风险**:失败受到 1 点体质损伤",荆棘陷阱,陷阱
森林小径3,"### 迷路
浓雾弥漫,你失去了方向。
**交互**:寻找路径(感知检定 3/2
**风险**:失败受到 1 点感知损伤",迷路,事件
塔楼楼梯1,"### 台阶坍塌
脚下的石阶突然碎裂。
**交互**:保持平衡(敏捷检定 3/1
**风险**:失败受到 1 点敏捷损伤",台阶坍塌,陷阱
```
### 示例 3NPC 名录
```csv
---
fields:
- name: name
description: NPC 名称
- name: role
description: 角色定位
- name: location
description: 出现地点
deck:
size: 54x86
grid: 4x6
---
label,body,name,role,location
1,"### 老妇人玛拉
**类型**:信息提供者/商人
**位置**:村庄入口
**出售**:草药、治疗药水
**情报**:知道关于森林的警告",老妇人玛拉,商人,村庄入口
2,"### 护卫队长塞里克
**类型**:任务发布者
**位置**:酒馆二楼
**任务**:调查废弃庄园
**秘密**:知道血契的真相",塞里克,任务发布者,酒馆
```
---
## 与组件集成
### `:md-deck` 组件
```markdown
:md-deck[./magic-items.csv]{size="63x88" grid="5x5" roll=true}
```
- `roll=true`:添加随机抽卡按钮
### `:md-table` 组件
```markdown
:md-table[./encounters.csv]{group="森林小径" remix=true}
```
- `group`:筛选特定组的数据
- `remix=true`:启用随机引用模式
---
## 最佳实践
1. **使用有意义的 label**:便于后续查找和修改
2. **在 Front Matter 中定义所有字段**:便于工具理解和处理
3. **使用 `{{prop}}` 变量**:减少重复内容,支持动态组合
4. **合理使用分组**:便于筛选和管理大量数据
5. **保持 body 的 Markdown 格式一致**:确保渲染效果统一
6. **为字段添加 `examples`**:帮助 AI 工具生成内容时参考

View File

@ -1,6 +1,6 @@
# MCP 服务器使用说明 # MCP 服务器使用说明
TTRPG Tools 提供了一个 MCP (Model Context Protocol) 服务器,用于与 AI 助手集成,自动化生成卡牌内容。 TTRPG Tools 提供了一个 MCP (Model Context Protocol) 服务器,用于与 AI 助手集成,自动化生成和管理卡牌内容。
## 命令结构 ## 命令结构
@ -79,11 +79,15 @@ ttrpg mcp generate-card-deck \
--grid "3x4" --grid "3x4"
``` ```
## MCP 工具generate_card_deck ## MCP 工具
通过 MCP 协议AI 助手可以调用 `generate_card_deck` 工具生成卡牌组。 通过 MCP 协议AI 助手可以调用以下工具:
### 工具参数 ### 快捷工具
#### `generate_card_deck` - 一站式生成卡牌组
快速生成完整的卡牌组,包括 Markdown 文件、CSV 数据文件和组件配置。
| 参数 | 类型 | 必需 | 说明 | | 参数 | 类型 | 必需 | 说明 |
|------|------|------|------| |------|------|------|------|
@ -94,8 +98,41 @@ ttrpg mcp generate-card-deck \
| `deck_config` | object | ✗ | md-deck 组件配置 | | `deck_config` | object | ✗ | md-deck 组件配置 |
| `description` | string | ✗ | 卡牌组描述 | | `description` | string | ✗ | 卡牌组描述 |
### card_template 结构 ### 核心工具
#### `deck_frontmatter_read` - 读取 CSV frontmatter
读取 CSV 文件的 frontmatter包含模板定义和 deck 配置)。
| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `csv_file` | string | ✓ | CSV 文件路径 |
**返回示例:**
```json
{
"fields": [
{ "name": "name", "description": "卡牌名称" },
{ "name": "type", "description": "卡牌类型" }
],
"deck": {
"size": "54x86",
"grid": "5x8"
}
}
```
#### `deck_frontmatter_write` - 写入 CSV frontmatter
写入或更新 CSV 文件的 frontmatter模板定义和 deck 配置)。
| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `csv_file` | string | ✓ | CSV 文件路径 |
| `frontmatter` | object | ✓ | 要写入的 frontmatter 数据 |
| `merge` | boolean | ✗ | 是否合并现有 frontmatter默认 true |
**frontmatter 结构:**
```json ```json
{ {
"fields": [ "fields": [
@ -105,84 +142,355 @@ ttrpg mcp generate-card-deck \
"examples": ["示例值 1", "示例值 2"] "examples": ["示例值 1", "示例值 2"]
} }
], ],
"examples": [ "deck": {
{ "size": "54x86",
"字段名称": "值 1", "grid": "5x8",
"字段名称 2": "值 2" "bleed": 1,
} "padding": 2,
] "shape": "rectangle",
} "layers": "name:1,2-3,12",
``` "back_layers": "back:1,2-8,12"
### deck_config 结构
```json
{
"size": "54x86",
"grid": "5x8",
"bleed": 1,
"padding": 2,
"shape": "rectangle",
"layers": "name:1,2-3,12 desc:1,4-8,10",
"back_layers": "back:1,2-8,12"
}
```
### 使用示例AI 助手)
**用户请求:**
> 帮我生成一个魔法物品卡牌组,包含 15 张卡牌,字段有名称、稀有度、效果描述
**AI 助手调用工具:**
```json
{
"deck_name": "魔法物品",
"output_dir": "./content",
"card_count": 15,
"card_template": {
"fields": [
{
"name": "name",
"description": "物品名称",
"examples": ["火球术卷轴", "治疗药水", "隐形斗篷"]
},
{
"name": "rarity",
"description": "稀有度",
"examples": ["稀有", "普通", "珍贵"]
},
{
"name": "effect",
"description": "效果描述",
"examples": ["造成 5d6 火焰伤害", "恢复 2d4+2 生命值", "隐身 1 小时"]
}
]
} }
} }
``` ```
**工具返回:** #### `deck_card_crud` - 卡牌 CRUD 操作
- 生成的 Markdown 文件路径
- 生成的 CSV 文件路径
- `:md-deck` 组件代码
## 输出文件 卡牌的创建、读取、更新、删除操作,支持批量操作。
运行工具后会生成: | 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `csv_file` | string | ✓ | CSV 文件路径 |
| `action` | string | ✓ | 操作类型:`create` \| `read` \| `update` \| `delete` |
| `cards` | object\|array | ✗ | 卡牌数据(单张或数组) |
| `label` | string\|array | ✗ | 要操作的卡牌 label用于 read/update/delete |
1. **Markdown 文件** (`{deck_name}.md`) **卡牌数据结构:**
- 包含卡牌组标题和描述 ```json
- 嵌入 `:md-deck` 组件代码 {
- 使用说明 "label": "1",
"name": "火球术卷轴",
"type": "法术",
"cost": "3",
"description": "造成 5d6 火焰伤害"
}
```
2. **CSV 文件** (`{deck_name}.csv`) **使用示例:**
- 包含所有卡牌数据
- 支持 `{{字段名}}` 变量语法
- 可使用 front matter 添加共享属性
3. **组件代码** ```json
- 可直接插入任何 Markdown 文件 // 创建单张卡牌
- 格式:`:md-deck[./xxx.csv]{size="54x86" grid="5x8" ...}` {
"csv_file": "./content/magic-items.csv",
"action": "create",
"cards": {
"label": "1",
"name": "火球术卷轴",
"type": "法术",
"cost": "3",
"description": "造成 5d6 火焰伤害"
}
}
// 批量创建卡牌
{
"csv_file": "./content/magic-items.csv",
"action": "create",
"cards": [
{ "name": "火球术卷轴", "type": "法术", "cost": "3" },
{ "name": "治疗药水", "type": "物品", "cost": "2" }
]
}
// 读取所有卡牌
{
"csv_file": "./content/magic-items.csv",
"action": "read"
}
// 读取指定卡牌
{
"csv_file": "./content/magic-items.csv",
"action": "read",
"label": ["1", "2"]
}
// 更新卡牌
{
"csv_file": "./content/magic-items.csv",
"action": "update",
"cards": { "label": "1", "cost": "4" }
}
// 删除卡牌
{
"csv_file": "./content/magic-items.csv",
"action": "delete",
"label": ["1", "2"]
}
```
#### `deck_ensure_preview` - 确保 Markdown 预览文件存在
确保 CSV 对应的 Markdown 预览文件存在,如果不存在则创建。
| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `csv_file` | string | ✓ | CSV 文件路径 |
| `md_file` | string | ✗ | Markdown 文件路径(可选,默认与 CSV 同名) |
| `title` | string | ✗ | 标题(可选,默认从 CSV 文件名推断) |
| `description` | string | ✗ | 描述(可选) |
## CSV 文件格式
CSV 文件使用 YAML frontmatter 定义模板和配置:
```csv
---
fields:
- name: name
description: 卡牌名称
- name: type
description: 卡牌类型
examples: [物品,法术,生物]
- name: cost
description: 费用
- name: description
description: 效果描述
deck:
size: 54x86
grid: 5x8
bleed: 1
padding: 2
---
label,name,type,cost,description
1火球术卷轴法术3造成 5d6 火焰伤害
2治疗药水物品2恢复 2d4+2 生命值
```
### Frontmatter 说明
- `fields`: 字段定义列表
- `name`: 字段名称(英文,用于 CSV 列名)
- `description`: 字段描述
- `examples`: 示例值列表(可选)
- `deck`: Deck 配置
- `size`: 卡牌尺寸,格式 "宽 x 高"(单位 mm
- `grid`: 网格布局,格式 "列 x 行"
- `bleed`: 出血边距mm
- `padding`: 内边距mm
- `shape`: 卡牌形状rectangle, circle, hex, diamond
- `layers`: 正面图层配置
- `back_layers`: 背面图层配置
### CSV 数据说明
- `label`: 卡牌标签(唯一标识,用于查找和修改)
- 其他列:由 frontmatter 中的 `fields` 定义
- `body`: 卡牌 body 内容(可选,支持 `{{字段名}}` 语法)
## MCP Resources
MCP 服务器提供以下 Resources包含 TTRPG Tools 的文档和参考材料:
### `ttrpg-docs://csv` - CSV 编写说明
**名称:** csv.md
**标题:** CSV 编写说明
**描述:** TTRPG Tools CSV 文件格式说明,包括 Front Matter、字段定义、变量语法等
**内容包含:**
- YAML Front Matter 结构fields、deck、自定义属性
- 数据类型word、paragraph、number、symbol、symbol_list
- 字段功能identify、compare、flavor、rule
- CSV 数据格式和特殊字符处理
- 变量语法(`{{prop}}`
- 分组支持
- 完整示例魔法物品、随机遭遇表、NPC 名录)
### `ttrpg-docs://markdown` - Markdown 编写说明
**名称:** markdown.md
**标题:** Markdown 编写说明
**描述:** TTRPG Tools Markdown 扩展语法和组件用法说明
**内容包含:**
- 基础语法GFM、marked-alert、marked-directive
- 指令语法格式
- 图标语法(`:[icon-name]`
- 组件库:
- `:md-dice` - 骰子组件
- `:md-link` - 链接组件
- `:md-bg` - 背景组件
- `:md-pins` - 标记组件
- `:md-table` - 表格组件
- `:md-deck` - 卡牌组件
- `:md-yarn-spinner` - 叙事线组件
- `:md-token` - 代币组件
- `:md-commander` - 命令追踪器
- YAML 标签
- Mermaid 图表
- 警告/提示块
- 文件引用规则
- 样式定制
### 使用示例
**列出所有 Resources**
```json
{
"jsonrpc": "2.0",
"id": 1,
"method": "resources/list"
}
```
**读取 Resource**
```json
{
"jsonrpc": "2.0",
"id": 2,
"method": "resources/read",
"params": {
"uri": "ttrpg-docs://csv"
}
}
```
---
## MCP Prompts
MCP 服务器提供以下 Prompts用于引导用户完成卡牌设计工作流
### `design-card-game` - 设计卡牌游戏
引导用户设计新的卡牌游戏系统,定义卡牌模板和字段结构。
**参数:**
| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `deck_name` | string | ✗ | 卡牌组名称 |
| `output_dir` | string | ✗ | 输出目录(相对路径) |
| `game_theme` | string | ✗ | 游戏主题/类型(如:奇幻、科幻、恐怖等) |
**使用示例:**
```json
{
"name": "design-card-game",
"arguments": {
"deck_name": "魔法物品",
"output_dir": "./content",
"game_theme": "奇幻"
}
}
```
**返回内容:**
Prompt 会返回一个对话式的引导流程,帮助 AI 助手了解:
1. 卡牌类型(角色卡、物品卡、法术卡等)
2. 核心机制(费用系统、稀有度、阵营等)
3. 卡牌信息字段
并提供常见配置的示例参考。
---
### `populate-deck` - 填充卡牌内容
为已有的卡牌组生成和填充卡牌内容。
**参数:**
| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `csv_file` | string | ✗ | CSV 文件路径 |
| `card_count` | number | ✗ | 要生成的卡牌数量 |
| `theme` | string | ✗ | 卡牌主题/描述 |
**使用示例:**
```json
{
"name": "populate-deck",
"arguments": {
"csv_file": "./content/magic-items.csv",
"card_count": 20,
"theme": "火焰主题法术"
}
}
```
**返回内容:**
Prompt 会引导 AI 助手:
1. 读取现有卡牌模板结构
2. 选择生成方式(随机/主题化/手动/扩展现有)
3. 生成符合主题的卡牌内容
---
### `setup-deck-display` - 配置卡牌显示
引导用户配置卡牌的显示参数(尺寸、布局、样式等)。
**参数:**
| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `csv_file` | string | ✗ | CSV 文件路径 |
| `usage` | string | ✗ | 卡牌用途(如:桌面游戏、印刷、在线预览等) |
**使用示例:**
```json
{
"name": "setup-deck-display",
"arguments": {
"csv_file": "./content/magic-items.csv",
"usage": "桌面游戏"
}
}
```
**返回内容:**
Prompt 会引导 AI 助手配置:
1. 卡牌尺寸(桥牌尺寸、标准尺寸、塔罗尺寸等)
2. 网格布局(每张 A4 纸的排列)
3. 出血边距、内边距
4. 卡牌形状
5. 图层配置(自动排版文字)
---
## 工作流示例
### 创建新卡牌组
1. **设计模板**:调用 `design-card-game` Prompt 引导设计字段结构
2. **定义模板**:调用 `deck_frontmatter_write` 创建 CSV 和 frontmatter
3. **创建预览**:调用 `deck_ensure_preview` 创建 Markdown 预览文件
4. **填充内容**:调用 `populate-deck` Prompt 引导生成卡牌内容
5. **添加卡牌**:调用 `deck_card_crud`action=create添加卡牌
6. **配置显示**:调用 `setup-deck-display` Prompt 配置显示参数
7. **保存配置**:调用 `deck_frontmatter_write` 更新 deck 配置
### 修改现有卡牌组
1. **读取模板**:调用 `deck_frontmatter_read` 获取当前配置
2. **读取卡牌**:调用 `deck_card_crud`action=read获取卡牌数据
3. **修改卡牌**:调用 `deck_card_crud`action=update更新卡牌
4. **更新预览**:调用 `deck_ensure_preview` 更新 Markdown 文件
### 快捷生成
直接调用 `generate_card_deck` 一站式生成完整卡牌组。
## 与 TTRPG Tools 集成 ## 与 TTRPG Tools 集成
@ -213,7 +521,13 @@ src/cli/
│ ├── compile.ts │ ├── compile.ts
│ └── mcp.ts # MCP 命令入口 │ └── mcp.ts # MCP 命令入口
├── tools/ ├── tools/
│ └── generate-card-deck.ts # 卡牌生成工具 │ ├── frontmatter/
│ │ ├── read-frontmatter.ts
│ │ └── write-frontmatter.ts
│ ├── card/
│ │ └── card-crud.ts
│ ├── ensure-deck-preview.ts
│ └── generate-card-deck.ts
└── index.ts └── index.ts
``` ```

7
package-lock.json generated
View File

@ -15,6 +15,7 @@
"chokidar": "^5.0.0", "chokidar": "^5.0.0",
"commander": "^14.0.3", "commander": "^14.0.3",
"csv-parse": "^6.1.0", "csv-parse": "^6.1.0",
"csv-stringify": "^6.7.0",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"marked": "^17.0.3", "marked": "^17.0.3",
"marked-alert": "^2.1.2", "marked-alert": "^2.1.2",
@ -4828,6 +4829,12 @@
"integrity": "sha512-CEE+jwpgLn+MmtCpVcPtiCZpVtB6Z2OKPTr34pycYYoL7sxdOkXDdQ4lRiw6ioC0q6BLqhc6cKweCVvral8yhw==", "integrity": "sha512-CEE+jwpgLn+MmtCpVcPtiCZpVtB6Z2OKPTr34pycYYoL7sxdOkXDdQ4lRiw6ioC0q6BLqhc6cKweCVvral8yhw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/csv-stringify": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.7.0.tgz",
"integrity": "sha512-UdtziYp5HuTz7e5j8Nvq+a/3HQo+2/aJZ9xntNTpmRRIg/3YYqDVgiS9fvAhtNbnyfbv2ZBe0bqCHqzhE7FqWQ==",
"license": "MIT"
},
"node_modules/cytoscape": { "node_modules/cytoscape": {
"version": "3.33.1", "version": "3.33.1",
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",

View File

@ -37,6 +37,7 @@
"chokidar": "^5.0.0", "chokidar": "^5.0.0",
"commander": "^14.0.3", "commander": "^14.0.3",
"csv-parse": "^6.1.0", "csv-parse": "^6.1.0",
"csv-stringify": "^6.7.0",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"marked": "^17.0.3", "marked": "^17.0.3",
"marked-alert": "^2.1.2", "marked-alert": "^2.1.2",

View File

@ -1,5 +1,29 @@
import { Command } from 'commander'; import { Command } from 'commander';
import { generateCardDeck, type GenerateCardDeckParams, type CardField } from '../tools/generate-card-deck.js'; import { generateCardDeck, type GenerateCardDeckParams, type CardField } from '../tools/generate-card-deck.js';
import { readFrontmatter, type ReadFrontmatterParams, type DeckFrontmatter } from '../tools/frontmatter/read-frontmatter.js';
import { writeFrontmatter, type WriteFrontmatterParams } from '../tools/frontmatter/write-frontmatter.js';
import { cardCrud, type CardCrudParams, type CardData } from '../tools/card/card-crud.js';
import { ensureDeckPreview, type EnsureDeckPreviewParams } from '../tools/ensure-deck-preview.js';
import {
designCardGame,
getDesignCardGamePrompt,
type DesignCardGameOptions
} from '../prompts/design-card-game.js';
import {
populateDeck,
getPopulateDeckPrompt,
type PopulateDeckOptions
} from '../prompts/populate-deck.js';
import {
setupDeckDisplay,
getSetupDeckDisplayPrompt,
type SetupDeckDisplayOptions
} from '../prompts/setup-deck-display.js';
import {
listResources,
readResource,
type DocResource
} from '../resources/docs.js';
/** /**
* MCP * MCP
@ -19,7 +43,7 @@ export const mcpCommand = new Command('mcp')
.description('启动 MCP 服务器') .description('启动 MCP 服务器')
.argument('[host]', '服务器地址', 'stdio') .argument('[host]', '服务器地址', 'stdio')
.option('-p, --port <port>', 'HTTP 端口(仅 HTTP 传输)', '3001') .option('-p, --port <port>', 'HTTP 端口(仅 HTTP 传输)', '3001')
.option('--cwd <dir>', '工作目录(工具调用的相对路径基准)', process.cwd()) .option('--cwd <dir>', '工作目录(工具调用的相对路径基准)', process.env.TTRPG_MCP_CWD || process.cwd())
.action(mcpServeAction) .action(mcpServeAction)
) )
.addCommand( .addCommand(
@ -50,6 +74,10 @@ async function mcpServeAction(host: string, options: MCPOptions) {
const { const {
CallToolRequestSchema, CallToolRequestSchema,
ListToolsRequestSchema, ListToolsRequestSchema,
ListPromptsRequestSchema,
GetPromptRequestSchema,
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} = await import('@modelcontextprotocol/sdk/types.js'); } = await import('@modelcontextprotocol/sdk/types.js');
const server = new Server( const server = new Server(
@ -60,6 +88,8 @@ async function mcpServeAction(host: string, options: MCPOptions) {
{ {
capabilities: { capabilities: {
tools: {}, tools: {},
prompts: {},
resources: {},
}, },
} }
); );
@ -69,171 +99,351 @@ async function mcpServeAction(host: string, options: MCPOptions) {
return { return {
tools: [ tools: [
{ {
name: 'generate_card_deck', name: 'deck_frontmatter_read',
description: '生成 TTRPG 卡牌组内容,包括 Markdown 介绍文件、CSV 数据文件和 md-deck 组件配置', description: '读取 CSV 文件的 frontmatter包含模板定义和 deck 配置)',
inputSchema: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
deck_name: { csv_file: {
type: 'string', type: 'string',
description: '卡牌组名称,将用于生成文件名', description: 'CSV 文件路径(相对路径相对于 MCP 服务器工作目录)',
}, },
output_dir: { },
required: ['csv_file'],
},
},
{
name: 'deck_frontmatter_write',
description: '写入/更新 CSV 文件的 frontmatter模板定义和 deck 配置)',
inputSchema: {
type: 'object',
properties: {
csv_file: {
type: 'string', type: 'string',
description: '输出目录路径(相对路径相对于 MCP 服务器工作目录)', description: 'CSV 文件路径',
}, },
card_count: { frontmatter: {
type: 'number',
description: '卡牌数量',
default: 10,
},
card_template: {
type: 'object', type: 'object',
description: '卡牌模板定义', description: '要写入的 frontmatter 数据',
properties: { properties: {
fields: { fields: {
type: 'array', type: 'array',
description: '卡牌字段列表', description: '字段定义列表',
items: { items: {
type: 'object', type: 'object',
properties: { properties: {
name: { name: { type: 'string', description: '字段名称' },
type: 'string', description: { type: 'string', description: '字段描述' },
description: '字段名称(英文,用于 CSV 列名)', examples: { type: 'array', items: { type: 'string' }, description: '示例值列表' },
},
description: {
type: 'string',
description: '字段描述',
},
examples: {
type: 'array',
description: '示例值列表',
items: { type: 'string' },
},
}, },
required: ['name'], required: ['name'],
}, },
}, },
examples: { deck: {
type: 'array', type: 'object',
description: '完整的卡牌示例数据', description: 'Deck 配置',
items: { properties: {
type: 'object', size: { type: 'string', description: '卡牌尺寸,格式 "宽 x 高"' },
additionalProperties: { type: 'string' }, grid: { type: 'string', description: '网格布局,格式 "列 x 行"表示卡牌排版区域分成多少行多少列用于显示字段。默认使用5x8。' },
bleed: { type: 'number', description: '出血边距mm' },
padding: { type: 'number', description: '内边距mm' },
shape: { type: 'string', enum: ['rectangle', 'circle', 'hex', 'diamond'] },
layers: { type: 'string', description: '字段的显示图层配置。如`title:1,1-5,1f8n body:1,3-5,8f3n`表示将title字段的内容显示在第1列1行到第5列1行的区域8mm字体上侧朝向北body覆盖1列3行到5列行3mm字体上侧朝向北。通常希望重要的辨识字段放在左侧如`rank:1,1-1,1f6 suit:1,2-1,2f6`。' },
back_layers: { type: 'string', description: '背面显示图层配置,与正面类似' },
}, },
}, },
}, },
required: ['fields'],
}, },
deck_config: { merge: {
type: 'object', type: 'boolean',
description: 'md-deck 组件配置', description: '是否合并现有 frontmatter默认 true',
properties: { default: true,
size: { },
type: 'string', },
description: '卡牌尺寸,格式 "宽 x 高"(单位 mm', required: ['csv_file', 'frontmatter'],
default: '54x86', },
}, },
grid: { {
type: 'string', name: 'deck_card_crud',
description: '网格布局,格式 "列 x 行"', description: '卡牌 CRUD 操作(创建/读取/更新/删除),支持批量操作',
default: '5x8', inputSchema: {
}, type: 'object',
bleed: { properties: {
type: 'number', csv_file: {
description: '出血边距mm', type: 'string',
default: 1, description: 'CSV 文件路径',
}, },
padding: { action: {
type: 'number', type: 'string',
description: '内边距mm', description: '操作类型',
default: 2, enum: ['create', 'read', 'update', 'delete'],
}, },
shape: { cards: {
type: 'string', type: ['array', 'object'],
description: '卡牌形状', description: '卡牌数据(单张或数组)',
enum: ['rectangle', 'circle', 'hex', 'diamond'], items: {
default: 'rectangle', type: 'object',
}, additionalProperties: { type: 'string', description: "字段内容可以使用markdown语法。使用:[attack]来表示名为attack的图标。使用{{prop}}来引用另一个字段或者frontmatter里的内容。" },
layers: {
type: 'string',
description: '正面图层配置,格式 "字段:行,列范围,字体大小"',
},
back_layers: {
type: 'string',
description: '背面图层配置',
},
}, },
}, },
label: {
type: ['string', 'array'],
description: '要操作的卡牌 label用于 read/update/delete',
items: { type: 'string' },
},
},
required: ['csv_file', 'action'],
},
},
{
name: 'deck_ensure_preview',
description: '确保 CSV 对应的 Markdown 预览文件存在',
inputSchema: {
type: 'object',
properties: {
csv_file: {
type: 'string',
description: 'CSV 文件路径',
},
md_file: {
type: 'string',
description: 'Markdown 文件路径(可选,默认与 CSV 同名)',
},
title: {
type: 'string',
description: '标题(可选,默认从 CSV 文件名推断)',
},
description: { description: {
type: 'string', type: 'string',
description: '卡牌组的介绍描述(可选)', description: '描述(可选)',
}, },
}, },
required: ['deck_name', 'output_dir'], required: ['csv_file'],
}, },
}, },
], ],
}; };
}); });
// 处理 Prompts 列表请求
server.setRequestHandler(ListPromptsRequestSchema, async () => {
return {
prompts: [
getDesignCardGamePrompt(),
getPopulateDeckPrompt(),
getSetupDeckDisplayPrompt(),
],
};
});
// 处理工具调用请求 // 处理工具调用请求
server.setRequestHandler(CallToolRequestSchema, async (request) => { server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params; const { name, arguments: args } = request.params;
if (name === 'generate_card_deck') { try {
try { switch (name) {
const params = args as unknown as GenerateCardDeckParams; case 'generate_card_deck': {
const params = args as unknown as GenerateCardDeckParams;
// 验证必需参数
if (!params.deck_name || !params.output_dir) {
return {
content: [
{
type: 'text',
text: '错误:缺少必需参数 deck_name 或 output_dir',
},
],
isError: true,
};
}
// 生成卡牌组(使用当前工作目录)
const result = generateCardDeck(params);
// 验证必需参数
if (!params.deck_name || !params.output_dir) {
return { return {
content: [ content: [
{ {
type: 'text', type: 'text',
text: '错误:缺少必需参数 deck_name 或 output_dir', text: result.message,
},
{
type: 'text',
text: `\n## 生成的组件代码\n\n\`\`\`markdown\n${result.deckComponent}\n\`\`\``,
}, },
], ],
isError: true,
}; };
} }
// 生成卡牌组(使用当前工作目录) case 'deck_frontmatter_read': {
const result = generateCardDeck(params); const params = args as unknown as ReadFrontmatterParams;
return { if (!params.csv_file) {
content: [ return {
{ content: [{ type: 'text', text: '错误:缺少必需参数 csv_file' }],
type: 'text', isError: true,
text: result.message, };
}, }
{
type: 'text', const result = readFrontmatter(params);
text: `\n## 生成的组件代码\n\n\`\`\`markdown\n${result.deckComponent}\n\`\`\``,
}, return {
], content: [
}; {
} catch (error) { type: 'text',
return { text: result.message,
content: [ },
{ ...(result.frontmatter ? [{
type: 'text', type: 'text',
text: `生成失败:${error instanceof Error ? error.message : '未知错误'}`, text: `\n## Frontmatter\n\n\`\`\`json\n${JSON.stringify(result.frontmatter, null, 2)}\n\`\`\``,
}, }] : []),
], ],
isError: true, };
}; }
case 'deck_frontmatter_write': {
const params = args as unknown as WriteFrontmatterParams;
if (!params.csv_file || !params.frontmatter) {
return {
content: [{ type: 'text', text: '错误:缺少必需参数 csv_file 或 frontmatter' }],
isError: true,
};
}
const result = writeFrontmatter(params);
return {
content: [{ type: 'text', text: result.success ? `${result.message}` : `${result.message}` }],
isError: !result.success,
};
}
case 'deck_card_crud': {
const params = args as unknown as CardCrudParams;
if (!params.csv_file || !params.action) {
return {
content: [{ type: 'text', text: '错误:缺少必需参数 csv_file 或 action' }],
isError: true,
};
}
const result = cardCrud(params);
return {
content: [
{
type: 'text',
text: result.success ? `${result.message}` : `${result.message}`,
},
...(result.cards && result.cards.length > 0 ? [{
type: 'text',
text: `\n## 卡牌数据\n\n\`\`\`json\n${JSON.stringify(result.cards, null, 2)}\n\`\`\``,
}] : []),
],
isError: !result.success,
};
}
case 'deck_ensure_preview': {
const params = args as unknown as EnsureDeckPreviewParams;
if (!params.csv_file) {
return {
content: [{ type: 'text', text: '错误:缺少必需参数 csv_file' }],
isError: true,
};
}
const result = ensureDeckPreview(params);
return {
content: [{ type: 'text', text: result.success ? `${result.message}` : `${result.message}` }],
isError: !result.success,
};
}
default:
return {
content: [{ type: 'text', text: `未知工具:${name}` }],
isError: true,
};
} }
} catch (error) {
return {
content: [{ type: 'text', text: `工具调用失败:${error instanceof Error ? error.message : '未知错误'}` }],
isError: true,
};
}
});
// 处理 Prompts 获取请求
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case 'design-card-game': {
const options = args as unknown as DesignCardGameOptions;
const result = designCardGame(options);
return {
description: '引导用户设计新的卡牌游戏系统,定义卡牌模板和字段结构',
messages: result.messages,
};
}
case 'populate-deck': {
const options = args as unknown as PopulateDeckOptions;
const result = populateDeck(options);
return {
description: '为已有的卡牌组生成和填充卡牌内容',
messages: result.messages,
};
}
case 'setup-deck-display': {
const options = args as unknown as SetupDeckDisplayOptions;
const result = setupDeckDisplay(options);
return {
description: '引导用户配置卡牌的显示参数(尺寸、布局、样式等)',
messages: result.messages,
};
}
default:
throw new Error(`未知 prompt${name}`);
}
} catch (error) {
throw error;
}
});
// 处理 Resources 列表请求
server.setRequestHandler(ListResourcesRequestSchema, async () => {
return {
resources: listResources().map(r => ({
uri: r.uri,
name: r.name,
title: r.title,
description: r.description,
mimeType: r.mimeType,
})),
};
});
// 处理 Resources 读取请求
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri } = request.params;
const resource = readResource(uri, process.cwd());
if (!resource) {
throw new Error(`Resource not found: ${uri}`);
} }
return { return {
content: [ contents: [resource],
{
type: 'text',
text: `未知工具:${name}`,
},
],
isError: true,
}; };
}); });

View File

@ -0,0 +1,299 @@
/**
* design-card-game Prompt
*
*
*/
export interface DesignCardGameOptions {
/**
*
*/
deck_name?: string;
/**
*
*/
output_dir?: string;
/**
* /
*/
game_theme?: string;
}
export interface DesignCardGameResult {
messages: PromptMessage[];
}
export interface PromptMessage {
role: 'user' | 'assistant';
content: TextContent | ImageContent | AudioContent | ResourceContent;
}
export interface TextContent {
type: 'text';
text: string;
}
export interface ImageContent {
type: 'image';
data: string;
mimeType: string;
}
export interface AudioContent {
type: 'audio';
data: string;
mimeType: string;
}
export interface ResourceContent {
type: 'resource';
resource: {
uri: string;
mimeType: string;
text?: string;
blob?: string;
};
}
/**
*
*/
export type DataType = 'word' | 'paragraph' | 'number' | 'symbol' | 'symbol_list';
/**
*
*/
export type FieldFunction = 'identify' | 'compare' | 'flavor' | 'rule';
/**
*
*/
export interface FieldDefinition {
name: string;
description: string;
dataType: DataType;
function: FieldFunction;
examples?: string[];
}
/**
*
*/
const DATA_TYPE_DESCRIPTIONS: Record<DataType, string> = {
word: '词语 - 短文本(如名称、类型、职业等)',
paragraph: '文本段落 - 长描述(如效果说明、背景故事等)',
number: '数字 - 可比较的数值(如费用、强度、等级等)',
symbol: '单一符号 - 图标/标记(如阵营符号、元素标志等)',
symbol_list: '符号列表 - 多个图标/标记(如属性加成列表、技能图标等)'
};
/**
*
*/
const FIELD_FUNCTION_DESCRIPTIONS: Record<FieldFunction, string> = {
identify: '辨识 - 用于识别卡牌身份(如名称、编号等)',
compare: '比较 - 用于游戏机制中的数值比较(如费用、攻击力等)',
flavor: '游戏风味描述 - 提供背景故事/氛围(如背景故事、引言等)',
rule: '规则确认 - 明确游戏规则相关的信息(如效果文本、触发条件等)'
};
/**
* design-card-game prompt
*/
export function designCardGame(options?: DesignCardGameOptions): DesignCardGameResult {
const deckName = options?.deck_name || '未命名卡牌组';
const outputDir = options?.output_dir || './content';
const gameTheme = options?.game_theme || '通用';
const messages: PromptMessage[] = [
{
role: 'user',
content: {
type: 'text',
text: `我想设计一个"${gameTheme}"主题的卡牌游戏,卡牌组名称为"${deckName}",输出目录为"${outputDir}"。请帮我设计卡牌的字段结构。`
}
},
{
role: 'assistant',
content: {
type: 'text',
text: `好的!我来帮你设计"${deckName}"卡牌组的字段结构。
---
## 1
****
-
-
-
-
****
- ...
---
## 2
| | | |
|------|------|------|
| word | | "火球术""战士""传说" |
| paragraph | | "造成 5d6 点火焰伤害,范围内的所有生物进行敏捷豁免" |
| number | | 3510 |
| symbol | / | 🔥🛡 |
| symbol_list | / | 🔥🔥🔥🛡💫 |
---
## 3
| | | |
|------|------|------|
| identify | | |
| compare | | |
| flavor | / | lore |
| rule | | |
---
## 4/
****
使 symbol symbol_list
---
##
\`\`\`
### 1[]
- ****[]
- ****[word/paragraph/number/symbol/symbol_list]
- ****[identify/compare/flavor/rule]
- ****[2-3 ]
### 2[]
...
\`\`\`
---
##
###
\`\`\`
### 1name
- ****
- ****word
- ****identify
- ****
### 2cost
- ****使
- ****number
- ****compare
- ****352
### 3type
- ****
- ****word
- ****identify
- ****
### 4rarity
- ****
- ****word
- ****compare
- ****
### 5effect
- ****使
- ****paragraph
- ****rule
- **** 5d6 2d4+2
\`\`\`
###
\`\`\`
### 1name
- ****
- ****word
- ****identify
- ****
### 2number
- ****
- ****number
- ****identify
- ****012
### 3element
- ****
- ****symbol
- ****flavor
- ****🔥💧💨🌍
### 4meaning
- ****
- ****paragraph
- ****rule
- ****
### 5reversed
- ****
- ****paragraph
- ****rule
- ****
\`\`\`
---
`
}
}
];
return { messages };
}
/**
* prompt
*/
export function getDesignCardGamePrompt() {
return {
name: 'design-card-game',
title: '设计卡牌游戏',
description: '引导用户设计新的卡牌游戏系统,定义卡牌模板和字段结构',
arguments: [
{
name: 'deck_name',
description: '卡牌组名称',
required: false
},
{
name: 'output_dir',
description: '输出目录(相对路径)',
required: false
},
{
name: 'game_theme',
description: '游戏主题/类型(如:奇幻、科幻、恐怖等)',
required: false
}
]
};
}

View File

@ -0,0 +1,187 @@
/**
* populate-deck Prompt
*
*
*/
export interface PopulateDeckOptions {
/**
* CSV
*/
csv_file?: string;
/**
*
*/
card_count?: number;
/**
* /
*/
theme?: string;
}
export interface PopulateDeckResult {
messages: PromptMessage[];
}
export interface PromptMessage {
role: 'user' | 'assistant';
content: TextContent | ImageContent | AudioContent | ResourceContent;
}
export interface TextContent {
type: 'text';
text: string;
}
export interface ImageContent {
type: 'image';
data: string;
mimeType: string;
}
export interface AudioContent {
type: 'audio';
data: string;
mimeType: string;
}
export interface ResourceContent {
type: 'resource';
resource: {
uri: string;
mimeType: string;
text?: string;
blob?: string;
};
}
/**
* populate-deck prompt
*/
export function populateDeck(options?: PopulateDeckOptions): PopulateDeckResult {
const csvFile = options?.csv_file || './content/deck.csv';
const cardCount = options?.card_count || 10;
const theme = options?.theme || '通用';
const messages: PromptMessage[] = [
{
role: 'user',
content: {
type: 'text',
text: `我想为卡牌组"${csvFile}"生成${cardCount}张卡牌,主题是"${theme}"。请帮我填充卡牌内容。`
}
},
{
role: 'assistant',
content: {
type: 'text',
text: `好的!我来帮你为"${csvFile}"生成${cardCount}张"${theme}"主题的卡牌。
...
---
** 1**
##
CSV frontmatter
CSV
##
### A
- examples
-
### B
- "森林生物""火焰法术""古代神器"
-
### C
-
- CSV
### D
-
-
---
****
name, type, cost, effect
3 "森林生物"
\`\`\`json
[
{
"label": "1",
"name": "荆棘狼",
"type": "生物",
"cost": "3",
"effect": "当荆棘狼进入战场时,对目标对手造成 1 点伤害"
},
{
"label": "2",
"name": "树精长老",
"type": "生物",
"cost": "5",
"effect": "树精长老具有 +0/+3 和'横置:回复 1 点生命值'"
},
{
"label": "3",
"name": "森林之灵",
"type": "生物",
"cost": "2",
"effect": "飞行,当森林之灵离场时,将一个 1/1 的孢子衍生物放入战场"
}
]
\`\`\`
---
1. A/B/C/D
2. B
3. 线`
}
}
];
return { messages };
}
/**
* prompt
*/
export function getPopulateDeckPrompt() {
return {
name: 'populate-deck',
title: '填充卡牌内容',
description: '为已有的卡牌组生成和填充卡牌内容',
arguments: [
{
name: 'csv_file',
description: 'CSV 文件路径',
required: false
},
{
name: 'card_count',
description: '要生成的卡牌数量',
required: false
},
{
name: 'theme',
description: '卡牌主题/描述',
required: false
}
]
};
}

View File

@ -0,0 +1,195 @@
/**
* setup-deck-display Prompt
*
*
*/
export interface SetupDeckDisplayOptions {
/**
* CSV
*/
csv_file?: string;
/**
*
*/
usage?: string;
}
export interface SetupDeckDisplayResult {
messages: PromptMessage[];
}
export interface PromptMessage {
role: 'user' | 'assistant';
content: TextContent | ImageContent | AudioContent | ResourceContent;
}
export interface TextContent {
type: 'text';
text: string;
}
export interface ImageContent {
type: 'image';
data: string;
mimeType: string;
}
export interface AudioContent {
type: 'audio';
data: string;
mimeType: string;
}
export interface ResourceContent {
type: 'resource';
resource: {
uri: string;
mimeType: string;
text?: string;
blob?: string;
};
}
/**
* setup-deck-display prompt
*/
export function setupDeckDisplay(options?: SetupDeckDisplayOptions): SetupDeckDisplayResult {
const csvFile = options?.csv_file || './content/deck.csv';
const usage = options?.usage || '桌面游戏';
const messages: PromptMessage[] = [
{
role: 'user',
content: {
type: 'text',
text: `我想配置"${csvFile}"卡牌组的显示参数,主要用于"${usage}"。请帮我设置合适的尺寸和布局。`
}
},
{
role: 'assistant',
content: {
type: 'text',
text: `好的!我来帮你配置"${csvFile}"卡牌组的显示参数。
##
###
| | (mm) | |
|---------|----------|------|
| | 57x89 | |
| | 63x88 | |
| | 44x68 | |
| | 57x57 | |
| | 70x120 | |
| | | |
---
##
A4
| | | A4 |
|---------|---------|-------------|
| 57x89 | 5 x 6 | 30 |
| 63x88 | 5 x 5 | 25 |
| 44x68 | 6 x 8 | 48 |
| 57x57 | 6 x 6 | 36 |
| 70x120 | 3 x 4 | 12 |
---
##
### (Bleed)
- 1mm
- 0mm
- 2-3mm
### (Padding)
- 2mm
- 1mm
- 3-4mm
###
- rectangle-
- circle
- hex
- diamond
---
##
\`字段名:起始行 - 结束行,字体大小\`
\`\`\`
layers="name:1-2,14 type:3-3,10 effect:4-8,9"
\`\`\`
- name 1-2 14
- type 3 10
- effect 4-8 9
---
****
1.
2.
3. /
4.
---
****
\`\`\`json
{
"deck": {
"size": "63x88",
"grid": "5x5",
"bleed": 1,
"padding": 2,
"shape": "rectangle",
"layers": "name:1-2,14 type:3-3,10 effect:4-8,9"
}
}
\`\`\``
}
}
];
return { messages };
}
/**
* prompt
*/
export function getSetupDeckDisplayPrompt() {
return {
name: 'setup-deck-display',
title: '配置卡牌显示',
description: '引导用户配置卡牌的显示参数(尺寸、布局、样式等)',
arguments: [
{
name: 'csv_file',
description: 'CSV 文件路径',
required: false
},
{
name: 'usage',
description: '卡牌用途(如:桌面游戏、印刷、在线预览等)',
required: false
}
]
};
}

75
src/cli/resources/docs.ts Normal file
View File

@ -0,0 +1,75 @@
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
/**
*
*/
export interface DocResource {
uri: string;
name: string;
title?: string;
description?: string;
mimeType?: string;
}
/**
*
*/
export const DOC_RESOURCES: DocResource[] = [
{
uri: 'ttrpg-docs://csv',
name: 'csv.md',
title: 'CSV 编写说明',
description: 'TTRPG Tools CSV 文件格式说明,包括 Front Matter、字段定义、变量语法等',
mimeType: 'text/markdown'
},
{
uri: 'ttrpg-docs://markdown',
name: 'markdown.md',
title: 'Markdown 编写说明',
description: 'TTRPG Tools Markdown 扩展语法和组件用法说明',
mimeType: 'text/markdown'
}
];
/**
*
*/
export function listResources(): DocResource[] {
return DOC_RESOURCES;
}
/**
*
* @param uri URI
* @param cwd
* @returns
*/
export function readResource(uri: string, cwd: string): {
uri: string;
mimeType: string;
text: string;
} | null {
// 解析 URI
const docName = uri.replace('ttrpg-docs://', '');
const fileName = `${docName}.md`;
const filePath = join(cwd, 'docs', fileName);
// 检查文件是否存在
if (!existsSync(filePath)) {
return null;
}
// 读取文件内容
try {
const content = readFileSync(filePath, 'utf-8');
return {
uri,
mimeType: 'text/markdown',
text: content
};
} catch (error) {
console.warn(`Failed to read resource ${uri}:`, error);
return null;
}
}

View File

@ -0,0 +1,301 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { parse } from 'csv-parse/browser/esm/sync';
import { stringify } from 'csv-stringify/browser/esm/sync';
import yaml from 'js-yaml';
import type { DeckFrontmatter } from '../frontmatter/read-frontmatter.js';
/**
*
*/
export interface CardData {
label?: string;
[key: string]: string | undefined;
}
/**
* CRUD
*/
export interface CardCrudParams {
/**
* CSV
*/
csv_file: string;
/**
*
*/
action: 'create' | 'read' | 'update' | 'delete';
/**
*
*/
cards?: CardData | CardData[];
/**
* / label read/update/delete
*/
label?: string | string[];
}
/**
* CRUD
*/
export interface CardCrudResult {
success: boolean;
message: string;
cards?: CardData[];
count?: number;
}
/**
* CSV frontmatter
*/
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
if (parts.length !== 3 || parts[0] !== '') {
return { csvContent: content };
}
try {
const frontmatterStr = parts[1].trim();
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
const csvContent = parts.slice(2).join('---\n').trimStart();
return { frontmatter, csvContent };
} catch (error) {
console.warn('Failed to parse front matter:', error);
return { csvContent: content };
}
}
/**
* frontmatter YAML
*/
function serializeFrontMatter(frontmatter: DeckFrontmatter): string {
const yamlStr = yaml.dump(frontmatter, {
indent: 2,
lineWidth: -1,
noRefs: true,
quotingType: '"',
forceQuotes: false
});
return `---\n${yamlStr}---\n`;
}
/**
* CSV frontmatter
*/
function loadCSVWithFrontmatter(filePath: string): {
frontmatter?: DeckFrontmatter;
records: CardData[];
headers: string[];
} {
const content = readFileSync(filePath, 'utf-8');
const { frontmatter, csvContent } = parseFrontMatter(content);
const records = parse(csvContent, {
columns: true,
comment: '#',
trim: true,
skipEmptyLines: true
}) as CardData[];
// 获取表头
const firstLine = csvContent.split('\n')[0];
const headers = firstLine.split(',').map(h => h.trim());
return { frontmatter, records, headers };
}
/**
* CSV frontmatter
*/
function saveCSVWithFrontmatter(
filePath: string,
frontmatter: DeckFrontmatter | undefined,
records: CardData[],
headers?: string[]
): void {
// 序列化 frontmatter
const frontmatterStr = frontmatter ? serializeFrontMatter(frontmatter) : '';
// 确定表头
if (!headers || headers.length === 0) {
// 从 records 和 frontmatter.fields 推断表头
headers = ['label'];
if (frontmatter?.fields && Array.isArray(frontmatter.fields)) {
for (const field of frontmatter.fields) {
if (field.name && typeof field.name === 'string') {
headers.push(field.name);
}
}
}
headers.push('body');
}
// 确保所有 record 都有 headers 中的列
for (const record of records) {
for (const header of headers) {
if (!(header in record)) {
record[header] = '';
}
}
}
// 序列化 CSV
const csvContent = stringify(records, {
header: true,
columns: headers
});
// 写入文件
writeFileSync(filePath, frontmatterStr + csvContent, 'utf-8');
}
/**
* label
*/
function generateNextLabel(records: CardData[]): string {
const maxLabel = records.reduce((max, record) => {
const label = record.label ? parseInt(record.label, 10) : 0;
return label > max ? label : max;
}, 0);
return (maxLabel + 1).toString();
}
/**
* CRUD
*/
export function cardCrud(params: CardCrudParams): CardCrudResult {
const { csv_file, action, cards, label } = params;
// 检查文件是否存在create 操作可以不存在)
if (action !== 'create' && !existsSync(csv_file)) {
return {
success: false,
message: `文件不存在:${csv_file}`
};
}
try {
let frontmatter: DeckFrontmatter | undefined;
let records: CardData[] = [];
let headers: string[] = [];
// 加载现有数据
if (existsSync(csv_file)) {
const data = loadCSVWithFrontmatter(csv_file);
frontmatter = data.frontmatter;
records = data.records;
headers = data.headers;
}
// 执行操作
switch (action) {
case 'create': {
const newCards = Array.isArray(cards) ? cards : (cards ? [cards] : []);
for (const card of newCards) {
if (!card.label) {
card.label = generateNextLabel(records);
}
records.push(card);
}
saveCSVWithFrontmatter(csv_file, frontmatter, records, headers);
return {
success: true,
message: `成功创建 ${newCards.length} 张卡牌`,
cards: newCards,
count: newCards.length
};
}
case 'read': {
const labelsToRead = Array.isArray(label) ? label : (label ? [label] : null);
let resultCards: CardData[];
if (labelsToRead && labelsToRead.length > 0) {
resultCards = records.filter(r => labelsToRead.includes(r.label || ''));
} else {
resultCards = records;
}
return {
success: true,
message: `成功读取 ${resultCards.length} 张卡牌`,
cards: resultCards,
count: resultCards.length
};
}
case 'update': {
const labelsToUpdate = Array.isArray(label) ? label : (label ? [label] : null);
const updateCards = Array.isArray(cards) ? cards : (cards ? [cards] : []);
let updatedCount = 0;
if (labelsToUpdate && labelsToUpdate.length > 0) {
// 按 label 更新
for (const updateCard of updateCards) {
const targetLabel = updateCard.label || labelsToUpdate[updatedCount % labelsToUpdate.length];
const index = records.findIndex(r => r.label === targetLabel);
if (index !== -1) {
records[index] = { ...records[index], ...updateCard };
updatedCount++;
}
}
} else {
// 按 cards 中的 label 更新
for (const updateCard of updateCards) {
if (updateCard.label) {
const index = records.findIndex(r => r.label === updateCard.label);
if (index !== -1) {
records[index] = { ...records[index], ...updateCard };
updatedCount++;
}
}
}
}
saveCSVWithFrontmatter(csv_file, frontmatter, records, headers);
return {
success: true,
message: `成功更新 ${updatedCount} 张卡牌`,
cards: updateCards,
count: updatedCount
};
}
case 'delete': {
const labelsToDelete = Array.isArray(label) ? label : (label ? [label] : null);
let deletedCount = 0;
if (labelsToDelete && labelsToDelete.length > 0) {
const beforeCount = records.length;
records = records.filter(r => !labelsToDelete.includes(r.label || ''));
deletedCount = beforeCount - records.length;
} else if (cards) {
const cardsToDelete = Array.isArray(cards) ? cards : [cards];
const beforeCount = records.length;
records = records.filter(r =>
!cardsToDelete.some(c => c.label && r.label === c.label)
);
deletedCount = beforeCount - records.length;
}
saveCSVWithFrontmatter(csv_file, frontmatter, records, headers);
return {
success: true,
message: `成功删除 ${deletedCount} 张卡牌`,
count: deletedCount
};
}
default:
return {
success: false,
message: `未知操作:${action}`
};
}
} catch (error) {
return {
success: false,
message: `操作失败:${error instanceof Error ? error.message : '未知错误'}`
};
}
}

View File

@ -0,0 +1,217 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { dirname, join, basename, extname } from 'path';
import yaml from 'js-yaml';
import type { DeckFrontmatter, DeckConfig } from './frontmatter/read-frontmatter.js';
/**
* Deck
*/
export interface EnsureDeckPreviewParams {
/**
* CSV
*/
csv_file: string;
/**
* Markdown CSV
*/
md_file?: string;
/**
* CSV
*/
title?: string;
/**
*
*/
description?: string;
}
/**
* Deck
*/
export interface EnsureDeckPreviewResult {
success: boolean;
message: string;
md_file?: string;
created?: boolean;
}
/**
* CSV frontmatter
*/
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
if (parts.length !== 3 || parts[0] !== '') {
return { csvContent: content };
}
try {
const frontmatterStr = parts[1].trim();
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
const csvContent = parts.slice(2).join('---\n').trimStart();
return { frontmatter, csvContent };
} catch (error) {
console.warn('Failed to parse front matter:', error);
return { csvContent: content };
}
}
/**
* md-deck
*/
function buildDeckComponent(csvPath: string, config?: DeckConfig): string {
const parts = [`:md-deck[${csvPath}]`];
const attrs: string[] = [];
if (config?.size) {
attrs.push(`size="${config.size}"`);
}
if (config?.grid) {
attrs.push(`grid="${config.grid}"`);
}
if (config?.bleed !== undefined && config.bleed !== 1) {
attrs.push(`bleed="${config.bleed}"`);
}
if (config?.padding !== undefined && config.padding !== 2) {
attrs.push(`padding="${config.padding}"`);
}
if (config?.shape && config.shape !== 'rectangle') {
attrs.push(`shape="${config.shape}"`);
}
if (config?.layers) {
attrs.push(`layers="${config.layers}"`);
}
if (config?.back_layers) {
attrs.push(`back-layers="${config.back_layers}"`);
}
if (attrs.length > 0) {
parts.push(`{${attrs.join(' ')}}`);
}
return parts.join('');
}
/**
* Markdown
*/
export function ensureDeckPreview(params: EnsureDeckPreviewParams): EnsureDeckPreviewResult {
const { csv_file, md_file, title, description } = params;
// 检查 CSV 文件是否存在
if (!existsSync(csv_file)) {
return {
success: false,
message: `CSV 文件不存在:${csv_file}`
};
}
// 确定 Markdown 文件路径
let mdFilePath = md_file;
if (!mdFilePath) {
// 使用与 CSV 相同的路径和文件名,只是扩展名不同
const dir = dirname(csv_file);
const name = basename(csv_file, extname(csv_file));
mdFilePath = join(dir, `${name}.md`);
}
const created = !existsSync(mdFilePath);
// 如果文件已存在,检查是否已有 md-deck 组件
if (!created) {
try {
const existingContent = readFileSync(mdFilePath, 'utf-8');
// 如果已有 md-deck 组件引用该 CSV直接返回
if (existingContent.includes(`:md-deck[${csv_file}]`) ||
existingContent.includes(`:md-deck[./${basename(csv_file)}]`)) {
return {
success: true,
message: `预览文件 ${mdFilePath} 已存在`,
md_file: mdFilePath,
created: false
};
}
} catch (error) {
// 读取失败,继续创建
}
}
// 读取 CSV 的 frontmatter 获取配置
let deckConfig: DeckConfig | undefined;
try {
const csvContent = readFileSync(csv_file, 'utf-8');
const { frontmatter } = parseFrontMatter(csvContent);
deckConfig = frontmatter?.deck;
} catch (error) {
// 忽略错误,使用默认配置
}
// 确定标题
let deckTitle = title;
if (!deckTitle) {
// 从 CSV 文件名推断
const name = basename(csv_file, extname(csv_file));
deckTitle = name.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
// 构建 md-deck 组件代码(使用相对路径)
const mdDir = dirname(mdFilePath);
const csvDir = dirname(csv_file);
let relativeCsvPath: string;
if (mdDir === csvDir) {
relativeCsvPath = `./${basename(csv_file)}`;
} else {
relativeCsvPath = `./${basename(csv_file)}`;
}
const deckComponent = buildDeckComponent(relativeCsvPath, deckConfig);
// 生成 Markdown 内容
const mdLines: string[] = [];
mdLines.push(`# ${deckTitle}`);
mdLines.push('');
if (description) {
mdLines.push(description);
mdLines.push('');
}
mdLines.push('## 卡牌预览');
mdLines.push('');
mdLines.push(deckComponent);
mdLines.push('');
mdLines.push('## 使用说明');
mdLines.push('');
mdLines.push('- 点击卡牌可以查看详情');
mdLines.push('- 使用右上角的按钮可以随机抽取卡牌');
mdLines.push('- 可以通过编辑面板调整卡牌样式和布局');
mdLines.push('');
// 写入文件
try {
writeFileSync(mdFilePath, mdLines.join('\n'), 'utf-8');
return {
success: true,
message: created
? `创建预览文件 ${mdFilePath}`
: `更新预览文件 ${mdFilePath}`,
md_file: mdFilePath,
created
};
} catch (error) {
return {
success: false,
message: `写入失败:${error instanceof Error ? error.message : '未知错误'}`
};
}
}

View File

@ -0,0 +1,154 @@
import { readFileSync, existsSync } from 'fs';
import yaml from 'js-yaml';
/**
* CSV frontmatter
*/
export interface ReadFrontmatterParams {
/**
* CSV MCP
*/
csv_file: string;
}
/**
* Frontmatter
*/
export interface DeckFrontmatter {
/**
*
*/
fields?: CardField[];
/**
* Deck
*/
deck?: DeckConfig;
/**
*
*/
[key: string]: unknown;
}
/**
*
*
* x,y,w,h
* 5x8 1,1,5,8
*
* 使 f:8 8mm
*
* 使 u:n/w/s/e 西
*/
export interface CardFieldStyle {
/**
* [x, y, w, h]
* [1, 1, 5, 8] (1,1) 5 8
*/
pos?: [number, number, number, number];
/**
* "f:8" 8mm
*/
font?: string;
/**
* "n" | "w" | "s" | "e"/西//
*/
up?: 'n' | 'w' | 's' | 'e';
}
/**
*
*/
export interface CardField {
name: string;
description?: string;
examples?: string[];
style?: CardFieldStyle;
[key: string]: unknown;
}
/**
* Deck
*/
export interface DeckConfig {
size?: string;
grid?: string;
bleed?: number;
padding?: number;
shape?: 'rectangle' | 'circle' | 'hex' | 'diamond';
layers?: string;
back_layers?: string;
[key: string]: unknown;
}
/**
* CSV frontmatter
*/
export interface ReadFrontmatterResult {
success: boolean;
frontmatter?: DeckFrontmatter;
message: string;
}
/**
* CSV frontmatter
*/
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
// 至少需要三个部分空字符串、front matter、CSV 内容
if (parts.length !== 3 || parts[0] !== '') {
return { csvContent: content };
}
try {
const frontmatterStr = parts[1].trim();
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
const csvContent = parts.slice(2).join('---\n').trimStart();
return { frontmatter, csvContent };
} catch (error) {
console.warn('Failed to parse front matter:', error);
return { csvContent: content };
}
}
/**
* CSV frontmatter
*/
export function readFrontmatter(params: ReadFrontmatterParams): ReadFrontmatterResult {
const { csv_file } = params;
// 检查文件是否存在
if (!existsSync(csv_file)) {
return {
success: false,
message: `文件不存在:${csv_file}`
};
}
try {
// 读取文件内容
const content = readFileSync(csv_file, 'utf-8');
// 解析 frontmatter
const { frontmatter } = parseFrontMatter(content);
if (!frontmatter) {
return {
success: true,
frontmatter: {},
message: `文件 ${csv_file} 没有 frontmatter返回空对象`
};
}
return {
success: true,
frontmatter,
message: `成功读取 ${csv_file} 的 frontmatter`
};
} catch (error) {
return {
success: false,
message: `读取失败:${error instanceof Error ? error.message : '未知错误'}`
};
}
}

View File

@ -0,0 +1,131 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import yaml from 'js-yaml';
import type { DeckFrontmatter } from './read-frontmatter.js';
/**
* CSV frontmatter
*/
export interface WriteFrontmatterParams {
/**
* CSV MCP
*/
csv_file: string;
/**
* frontmatter
*/
frontmatter: DeckFrontmatter;
/**
* frontmatter true
*/
merge?: boolean;
}
/**
* CSV frontmatter
*/
export interface WriteFrontmatterResult {
success: boolean;
message: string;
csv_file?: string;
}
/**
* CSV frontmatter
*/
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
// 至少需要三个部分空字符串、front matter、CSV 内容
if (parts.length !== 3 || parts[0] !== '') {
return { csvContent: content };
}
try {
const frontmatterStr = parts[1].trim();
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
const csvContent = parts.slice(2).join('---\n').trimStart();
return { frontmatter, csvContent };
} catch (error) {
console.warn('Failed to parse front matter:', error);
return { csvContent: content };
}
}
/**
* frontmatter YAML
*/
function serializeFrontMatter(frontmatter: DeckFrontmatter): string {
const yamlStr = yaml.dump(frontmatter, {
indent: 2,
lineWidth: -1, // 不限制行宽
noRefs: true, // 不使用引用
quotingType: '"',
forceQuotes: false
});
return `---\n${yamlStr}---\n`;
}
/**
* / CSV frontmatter
*/
export function writeFrontmatter(params: WriteFrontmatterParams): WriteFrontmatterResult {
const { csv_file, frontmatter, merge = true } = params;
let csvContent = '';
let existingFrontmatter: DeckFrontmatter | undefined;
// 如果文件存在且需要合并,先读取现有内容
if (merge && existsSync(csv_file)) {
try {
const content = readFileSync(csv_file, 'utf-8');
const result = parseFrontMatter(content);
existingFrontmatter = result.frontmatter;
csvContent = result.csvContent;
} catch (error) {
return {
success: false,
message: `读取现有文件失败:${error instanceof Error ? error.message : '未知错误'}`
};
}
}
// 合并或替换 frontmatter
const finalFrontmatter = merge && existingFrontmatter
? { ...existingFrontmatter, ...frontmatter }
: frontmatter;
// 序列化 frontmatter
const frontmatterStr = serializeFrontMatter(finalFrontmatter);
// 如果文件不存在或没有 CSV 内容,创建一个空的 CSV 内容
if (!csvContent.trim()) {
// 从 frontmatter 推断 CSV 表头
const headers = ['label'];
if (finalFrontmatter.fields && Array.isArray(finalFrontmatter.fields)) {
for (const field of finalFrontmatter.fields) {
if (field.name && typeof field.name === 'string') {
headers.push(field.name);
}
}
}
headers.push('body');
csvContent = headers.join(',') + '\n';
}
// 写入文件
try {
const fullContent = frontmatterStr + csvContent;
writeFileSync(csv_file, fullContent, 'utf-8');
return {
success: true,
message: `成功写入 frontmatter 到 ${csv_file}`,
csv_file
};
} catch (error) {
return {
success: false,
message: `写入失败:${error instanceof Error ? error.message : '未知错误'}`
};
}
}

View File

@ -1,6 +1,32 @@
import { writeFileSync, mkdirSync, existsSync } from 'fs'; import { writeFileSync, mkdirSync, existsSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
/**
*
*
* x,y,w,h
* 5x8 1,1,5,8
*
* 使 f:8 8mm
*
* 使 u:n/w/s/e 西
*/
export interface CardFieldStyle {
/**
* [x, y, w, h]
* [1, 1, 5, 8] (1,1) 5 8
*/
pos?: [number, number, number, number];
/**
* "f:8" 8mm
*/
font?: string;
/**
* "n" | "w" | "s" | "e"/西//
*/
up?: 'n' | 'w' | 's' | 'e';
}
/** /**
* *
*/ */
@ -8,6 +34,7 @@ export interface CardField {
name: string; name: string;
description?: string; description?: string;
examples?: string[]; examples?: string[];
style?: CardFieldStyle;
} }
/** /**

View File

@ -16,9 +16,9 @@ function ensureIndexLoaded(): Promise<void> {
if (indexLoadPromise) return indexLoadPromise; if (indexLoadPromise) return indexLoadPromise;
indexLoadPromise = (async () => { indexLoadPromise = (async () => {
// 尝试 CLI 环境:从 /__FILE_INDEX.json 加载 // 尝试 CLI 环境:从 /__CONTENT_INDEX.json 加载
try { try {
const response = await fetch("/__FILE_INDEX.json"); const response = await fetch("/__CONTENT_INDEX.json");
if (response.ok) { if (response.ok) {
const index = await response.json(); const index = await response.json();
fileIndex = { ...fileIndex, ...index }; fileIndex = { ...fileIndex, ...index };