fix: mcp
This commit is contained in:
parent
eecf429a20
commit
62aff91a86
256
docs/mcp.md
256
docs/mcp.md
|
|
@ -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,171 @@ ttrpg mcp generate-card-deck \
|
||||||
"examples": ["示例值 1", "示例值 2"]
|
"examples": ["示例值 1", "示例值 2"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"examples": [
|
"deck": {
|
||||||
{
|
|
||||||
"字段名称": "值 1",
|
|
||||||
"字段名称 2": "值 2"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### deck_config 结构
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"size": "54x86",
|
"size": "54x86",
|
||||||
"grid": "5x8",
|
"grid": "5x8",
|
||||||
"bleed": 1,
|
"bleed": 1,
|
||||||
"padding": 2,
|
"padding": 2,
|
||||||
"shape": "rectangle",
|
"shape": "rectangle",
|
||||||
"layers": "name:1,2-3,12 desc:1,4-8,10",
|
"layers": "name:1,2-3,12",
|
||||||
"back_layers": "back:1,2-8,12"
|
"back_layers": "back:1,2-8,12"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 使用示例(AI 助手)
|
#### `deck_card_crud` - 卡牌 CRUD 操作
|
||||||
|
|
||||||
**用户请求:**
|
卡牌的创建、读取、更新、删除操作,支持批量操作。
|
||||||
> 帮我生成一个魔法物品卡牌组,包含 15 张卡牌,字段有名称、稀有度、效果描述
|
|
||||||
|
|
||||||
**AI 助手调用工具:**
|
| 参数 | 类型 | 必需 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `csv_file` | string | ✓ | CSV 文件路径 |
|
||||||
|
| `action` | string | ✓ | 操作类型:`create` \| `read` \| `update` \| `delete` |
|
||||||
|
| `cards` | object\|array | ✗ | 卡牌数据(单张或数组) |
|
||||||
|
| `label` | string\|array | ✗ | 要操作的卡牌 label(用于 read/update/delete) |
|
||||||
|
|
||||||
|
**卡牌数据结构:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"deck_name": "魔法物品",
|
"label": "1",
|
||||||
"output_dir": "./content",
|
"name": "火球术卷轴",
|
||||||
"card_count": 15,
|
"type": "法术",
|
||||||
"card_template": {
|
"cost": "3",
|
||||||
"fields": [
|
"description": "造成 5d6 火焰伤害"
|
||||||
{
|
|
||||||
"name": "name",
|
|
||||||
"description": "物品名称",
|
|
||||||
"examples": ["火球术卷轴", "治疗药水", "隐形斗篷"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "rarity",
|
|
||||||
"description": "稀有度",
|
|
||||||
"examples": ["稀有", "普通", "珍贵"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "effect",
|
|
||||||
"description": "效果描述",
|
|
||||||
"examples": ["造成 5d6 火焰伤害", "恢复 2d4+2 生命值", "隐身 1 小时"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**工具返回:**
|
**使用示例:**
|
||||||
- 生成的 Markdown 文件路径
|
|
||||||
- 生成的 CSV 文件路径
|
|
||||||
- `:md-deck` 组件代码
|
|
||||||
|
|
||||||
## 输出文件
|
```json
|
||||||
|
// 创建单张卡牌
|
||||||
|
{
|
||||||
|
"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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
1. **Markdown 文件** (`{deck_name}.md`)
|
// 读取所有卡牌
|
||||||
- 包含卡牌组标题和描述
|
{
|
||||||
- 嵌入 `:md-deck` 组件代码
|
"csv_file": "./content/magic-items.csv",
|
||||||
- 使用说明
|
"action": "read"
|
||||||
|
}
|
||||||
|
|
||||||
2. **CSV 文件** (`{deck_name}.csv`)
|
// 读取指定卡牌
|
||||||
- 包含所有卡牌数据
|
{
|
||||||
- 支持 `{{字段名}}` 变量语法
|
"csv_file": "./content/magic-items.csv",
|
||||||
- 可使用 front matter 添加共享属性
|
"action": "read",
|
||||||
|
"label": ["1", "2"]
|
||||||
|
}
|
||||||
|
|
||||||
3. **组件代码**
|
// 更新卡牌
|
||||||
- 可直接插入任何 Markdown 文件
|
{
|
||||||
- 格式:`:md-deck[./xxx.csv]{size="54x86" grid="5x8" ...}`
|
"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 内容(可选,支持 `{{字段名}}` 语法)
|
||||||
|
|
||||||
|
## 工作流示例
|
||||||
|
|
||||||
|
### 创建新卡牌组
|
||||||
|
|
||||||
|
1. **定义模板**:调用 `deck_frontmatter_write` 创建 CSV 和 frontmatter
|
||||||
|
2. **创建预览**:调用 `deck_ensure_preview` 创建 Markdown 预览文件
|
||||||
|
3. **添加卡牌**:调用 `deck_card_crud`(action=create)添加卡牌
|
||||||
|
|
||||||
|
### 修改现有卡牌组
|
||||||
|
|
||||||
|
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 +337,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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MCP 服务器命令
|
* MCP 服务器命令
|
||||||
|
|
@ -70,7 +74,7 @@ async function mcpServeAction(host: string, options: MCPOptions) {
|
||||||
tools: [
|
tools: [
|
||||||
{
|
{
|
||||||
name: 'generate_card_deck',
|
name: 'generate_card_deck',
|
||||||
description: '生成 TTRPG 卡牌组内容,包括 Markdown 介绍文件、CSV 数据文件和 md-deck 组件配置',
|
description: '生成 TTRPG 卡牌组内容,包括 Markdown 介绍文件、CSV 数据文件和 md-deck 组件配置(一站式快捷工具)',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
|
|
@ -173,6 +177,129 @@ async function mcpServeAction(host: string, options: MCPOptions) {
|
||||||
required: ['deck_name', 'output_dir'],
|
required: ['deck_name', 'output_dir'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'deck_frontmatter_read',
|
||||||
|
description: '读取 CSV 文件的 frontmatter(包含模板定义和 deck 配置)',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
csv_file: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'CSV 文件路径(相对路径相对于 MCP 服务器工作目录)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['csv_file'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'deck_frontmatter_write',
|
||||||
|
description: '写入/更新 CSV 文件的 frontmatter(模板定义和 deck 配置)',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
csv_file: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'CSV 文件路径',
|
||||||
|
},
|
||||||
|
frontmatter: {
|
||||||
|
type: 'object',
|
||||||
|
description: '要写入的 frontmatter 数据',
|
||||||
|
properties: {
|
||||||
|
fields: {
|
||||||
|
type: 'array',
|
||||||
|
description: '字段定义列表',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string', description: '字段名称' },
|
||||||
|
description: { type: 'string', description: '字段描述' },
|
||||||
|
examples: { type: 'array', items: { type: 'string' }, description: '示例值列表' },
|
||||||
|
},
|
||||||
|
required: ['name'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
deck: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'Deck 配置',
|
||||||
|
properties: {
|
||||||
|
size: { type: 'string', description: '卡牌尺寸,格式 "宽 x 高"' },
|
||||||
|
grid: { type: 'string', description: '网格布局,格式 "列 x 行"' },
|
||||||
|
bleed: { type: 'number', description: '出血边距(mm)' },
|
||||||
|
padding: { type: 'number', description: '内边距(mm)' },
|
||||||
|
shape: { type: 'string', enum: ['rectangle', 'circle', 'hex', 'diamond'] },
|
||||||
|
layers: { type: 'string', description: '正面图层配置' },
|
||||||
|
back_layers: { type: 'string', description: '背面图层配置' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
merge: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: '是否合并现有 frontmatter(默认 true)',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['csv_file', 'frontmatter'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'deck_card_crud',
|
||||||
|
description: '卡牌 CRUD 操作(创建/读取/更新/删除),支持批量操作',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
csv_file: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'CSV 文件路径',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'string',
|
||||||
|
description: '操作类型',
|
||||||
|
enum: ['create', 'read', 'update', 'delete'],
|
||||||
|
},
|
||||||
|
cards: {
|
||||||
|
type: ['array', 'object'],
|
||||||
|
description: '卡牌数据(单张或数组)',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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: {
|
||||||
|
type: 'string',
|
||||||
|
description: '描述(可选)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['csv_file'],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
@ -181,8 +308,9 @@ async function mcpServeAction(host: string, options: MCPOptions) {
|
||||||
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) {
|
||||||
|
case 'generate_card_deck': {
|
||||||
const params = args as unknown as GenerateCardDeckParams;
|
const params = args as unknown as GenerateCardDeckParams;
|
||||||
|
|
||||||
// 验证必需参数
|
// 验证必需参数
|
||||||
|
|
@ -213,28 +341,109 @@ async function mcpServeAction(host: string, options: MCPOptions) {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
} catch (error) {
|
}
|
||||||
|
|
||||||
|
case 'deck_frontmatter_read': {
|
||||||
|
const params = args as unknown as ReadFrontmatterParams;
|
||||||
|
|
||||||
|
if (!params.csv_file) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [{ type: 'text', text: '错误:缺少必需参数 csv_file' }],
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: `生成失败:${error instanceof Error ? error.message : '未知错误'}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
isError: true,
|
isError: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
const result = readFrontmatter(params);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: `未知工具:${name}`,
|
text: result.message,
|
||||||
},
|
},
|
||||||
|
...(result.frontmatter ? [{
|
||||||
|
type: 'text',
|
||||||
|
text: `\n## Frontmatter\n\n\`\`\`json\n${JSON.stringify(result.frontmatter, null, 2)}\n\`\`\``,
|
||||||
|
}] : []),
|
||||||
],
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 启动服务器
|
// 启动服务器
|
||||||
|
|
|
||||||
|
|
@ -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 : '未知错误'}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 : '未知错误'}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卡牌字段定义
|
||||||
|
*/
|
||||||
|
export interface CardField {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
examples?: string[];
|
||||||
|
[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 : '未知错误'}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 : '未知错误'}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue