diff --git a/src/cli/commands/mcp.ts b/src/cli/commands/mcp.ts index a0f70f3..e40bc85 100644 --- a/src/cli/commands/mcp.ts +++ b/src/cli/commands/mcp.ts @@ -3,7 +3,7 @@ import { generateCardDeck, type GenerateCardDeckParams, type CardField } from '. 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 { previewDeck, type PreviewDeckParams } from '../tools/preview-deck.js'; import { designCardGame, getDesignCardGamePrompt, @@ -24,6 +24,7 @@ import { readResource, type DocResource } from '../resources/docs.js'; +import { serveCommand } from './serve.js'; /** * MCP 服务器命令 @@ -68,6 +69,13 @@ async function mcpServeAction(host: string, options: MCPOptions) { process.chdir(cwd); console.error(`MCP 服务器工作目录:${cwd}`); + // 启动 serve 服务器(在后台运行) + console.error('启动预览服务器...'); + serveCommand(cwd, { port: '3000' }); + + // 等待服务器启动 + await new Promise(resolve => setTimeout(resolve, 1000)); + // 动态导入 MCP SDK const { Server } = await import('@modelcontextprotocol/sdk/server/index.js'); const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js'); @@ -196,8 +204,8 @@ async function mcpServeAction(host: string, options: MCPOptions) { }, }, { - name: 'deck_ensure_preview', - description: '确保 CSV 对应的 Markdown 预览文件存在', + name: 'deck_preview', + description: '保存 CSV 对应的 Markdown 预览文件并打开浏览器', inputSchema: { type: 'object', properties: { @@ -346,8 +354,8 @@ async function mcpServeAction(host: string, options: MCPOptions) { }; } - case 'deck_ensure_preview': { - const params = args as unknown as EnsureDeckPreviewParams; + case 'deck_preview': { + const params = args as unknown as PreviewDeckParams; if (!params.csv_file) { return { @@ -356,10 +364,15 @@ async function mcpServeAction(host: string, options: MCPOptions) { }; } - const result = ensureDeckPreview(params); + const result = previewDeck(params); return { - content: [{ type: 'text', text: result.success ? `✅ ${result.message}` : `❌ ${result.message}` }], + content: [{ + type: 'text', + text: result.success + ? `✅ ${result.message}\n\n预览 URL: ${result.preview_url}` + : `❌ ${result.message}` + }], isError: !result.success, }; } diff --git a/src/cli/tools/preview-deck.ts b/src/cli/tools/preview-deck.ts new file mode 100644 index 0000000..eafb3fa --- /dev/null +++ b/src/cli/tools/preview-deck.ts @@ -0,0 +1,251 @@ +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { dirname, join, basename, extname, relative } from 'path'; +import { execSync } from 'child_process'; +import yaml from 'js-yaml'; +import type { DeckFrontmatter, DeckConfig } from './frontmatter/read-frontmatter.js'; + +/** + * 预览 Deck 的参数 + */ +export interface PreviewDeckParams { + /** + * CSV 文件路径 + */ + csv_file: string; + /** + * Markdown 文件路径(可选,默认与 CSV 同名) + */ + md_file?: string; + /** + * 标题(可选,默认从 CSV 文件名推断) + */ + title?: string; + /** + * 描述(可选) + */ + description?: string; +} + +/** + * 预览 Deck 的结果 + */ +export interface PreviewDeckResult { + success: boolean; + message: string; + md_file?: string; + created?: boolean; + preview_url?: string; +} + +/** + * 解析 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(''); +} + +/** + * 打开浏览器 + */ +function openBrowser(url: string) { + try { + // Windows 使用 start 命令 + if (process.platform === 'win32') { + execSync(`start "" "${url}"`, { stdio: 'ignore' }); + } else if (process.platform === 'darwin') { + execSync(`open "${url}"`, { stdio: 'ignore' }); + } else { + execSync(`xdg-open "${url}"`, { stdio: 'ignore' }); + } + console.error(`已打开浏览器:${url}`); + } catch (error) { + console.error('打开浏览器失败:', error); + } +} + +/** + * 预览 Deck - 保存 Markdown 并打开浏览器 + */ +export function previewDeck(params: PreviewDeckParams): PreviewDeckResult { + 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)}]`)) { + // 仍然打开浏览器 + const relativeMdPath = relative(process.cwd(), mdFilePath).split('\\').join('/'); + const previewUrl = `http://localhost:3000/${relativeMdPath}`; + openBrowser(previewUrl); + return { + success: true, + message: `预览文件 ${mdFilePath} 已存在`, + md_file: mdFilePath, + created: false, + preview_url: previewUrl + }; + } + } 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'); + + // 计算预览 URL + const relativeMdPath = relative(process.cwd(), mdFilePath).split('\\').join('/'); + const previewUrl = `http://localhost:3000/${relativeMdPath.slice(0, -3)}`; + + // 打开浏览器 + openBrowser(previewUrl); + + return { + success: true, + message: created + ? `创建预览文件 ${mdFilePath}` + : `更新预览文件 ${mdFilePath}`, + md_file: mdFilePath, + created, + preview_url: previewUrl + }; + } catch (error) { + return { + success: false, + message: `写入失败:${error instanceof Error ? error.message : '未知错误'}` + }; + } +}