Merge branch 'feature/mcp-ui' into dev
This commit is contained in:
commit
9a496b955c
1063
docs/mcp-app-training.md
Normal file
1063
docs/mcp-app-training.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -22,6 +22,7 @@ class SkillItem(BaseModel):
|
||||
name: str
|
||||
description: str
|
||||
user_skill: bool = False
|
||||
category: str = "other"
|
||||
|
||||
|
||||
class SkillListResponse(BaseModel):
|
||||
@ -35,6 +36,7 @@ class SkillValidationResult:
|
||||
valid: bool
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
|
||||
@ -268,7 +270,8 @@ def parse_plugin_json(plugin_json_path: str) -> SkillValidationResult:
|
||||
return SkillValidationResult(
|
||||
valid=True,
|
||||
name=plugin_config['name'],
|
||||
description=plugin_config['description']
|
||||
description=plugin_config['description'],
|
||||
category=plugin_config.get('category'),
|
||||
)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
@ -335,7 +338,8 @@ def parse_skill_frontmatter(skill_md_path: str) -> SkillValidationResult:
|
||||
return SkillValidationResult(
|
||||
valid=True,
|
||||
name=metadata['name'],
|
||||
description=metadata['description']
|
||||
description=metadata['description'],
|
||||
category=metadata.get('category'),
|
||||
)
|
||||
|
||||
except yaml.YAMLError as e:
|
||||
@ -411,10 +415,13 @@ def get_skill_metadata_legacy(skill_path: str) -> Optional[dict]:
|
||||
"""
|
||||
result = get_skill_metadata(skill_path)
|
||||
if result.valid:
|
||||
return {
|
||||
ret = {
|
||||
'name': result.name,
|
||||
'description': result.description
|
||||
'description': result.description,
|
||||
}
|
||||
if result.category:
|
||||
ret['category'] = result.category
|
||||
return ret
|
||||
return None
|
||||
|
||||
|
||||
@ -457,7 +464,8 @@ def get_official_skills(base_dir: str) -> List[SkillItem]:
|
||||
skills.append(SkillItem(
|
||||
name=metadata['name'],
|
||||
description=metadata['description'],
|
||||
user_skill=False
|
||||
user_skill=False,
|
||||
category=metadata.get('category', 'other'),
|
||||
))
|
||||
skill_names.add(skill_name)
|
||||
logger.debug(f"Found official skill: {metadata['name']} from {official_skills_dir}")
|
||||
@ -490,7 +498,8 @@ def get_user_skills(base_dir: str, bot_id: str) -> List[SkillItem]:
|
||||
skills.append(SkillItem(
|
||||
name=metadata['name'],
|
||||
description=metadata['description'],
|
||||
user_skill=True
|
||||
user_skill=True,
|
||||
category=metadata.get('category', 'custom'),
|
||||
))
|
||||
logger.debug(f"Found user skill: {metadata['name']}")
|
||||
|
||||
|
||||
@ -18,5 +18,6 @@
|
||||
"{bot_id}"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"category": "Data & Retrieval"
|
||||
}
|
||||
|
||||
@ -18,5 +18,6 @@
|
||||
"{bot_id}"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"category": "Data & Retrieval"
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "data-dashboard",
|
||||
"description": "Renders data as an interactive dashboard card UI using the mcp-ui protocol.",
|
||||
"category": "Interactive UI",
|
||||
"hooks": {
|
||||
"PrePrompt": [
|
||||
{
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
name: docx
|
||||
description: "Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. When Claude needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks"
|
||||
license: Proprietary. LICENSE.txt has complete terms
|
||||
category: Document Processing
|
||||
---
|
||||
|
||||
# DOCX creation, editing, and analysis
|
||||
|
||||
@ -16,6 +16,7 @@ metadata:
|
||||
- node
|
||||
- npm
|
||||
primaryEnv: SMTP_PASS
|
||||
category: Communication
|
||||
---
|
||||
|
||||
# IMAP/SMTP Email Tool
|
||||
|
||||
@ -13,7 +13,11 @@
|
||||
"mcp_ui": {
|
||||
"transport": "stdio",
|
||||
"command": "python",
|
||||
"args": ["./ui_render_server.py", "{bot_id}"]
|
||||
"args": [
|
||||
"./ui_render_server.py",
|
||||
"{bot_id}"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"category": "Interactive UI"
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
name: pdf
|
||||
description: Comprehensive PDF manipulation toolkit for extracting text and tables, creating new PDFs, merging/splitting documents, and handling forms. When Claude needs to fill in a PDF form or programmatically process, generate, or analyze PDF documents at scale.
|
||||
license: Proprietary. LICENSE.txt has complete terms
|
||||
category: Document Processing
|
||||
---
|
||||
|
||||
# PDF Processing Guide
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
name: pptx
|
||||
description: "Presentation creation, editing, and analysis. When Claude needs to work with presentations (.pptx files) for: (1) Creating new presentations, (2) Modifying or editing content, (3) Working with layouts, (4) Adding comments or speaker notes, or any other presentation tasks"
|
||||
license: Proprietary. LICENSE.txt has complete terms
|
||||
category: Document Processing
|
||||
---
|
||||
|
||||
# PPTX creation, editing, and analysis
|
||||
|
||||
@ -5,6 +5,7 @@ compatibility: Requires Python 3.8+ and PyYAML. Uses AWS SigV4 signing (no exter
|
||||
metadata:
|
||||
author: foundra
|
||||
version: "2.1"
|
||||
category: Web Services
|
||||
---
|
||||
|
||||
# R2 Upload
|
||||
|
||||
@ -8,5 +8,6 @@
|
||||
"command": "python scripts/schedule_manager.py list --format brief"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"category": "Task Scheduling"
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
---
|
||||
name: schedule-job
|
||||
description: Scheduled Task Management - Create, manage, and view scheduled tasks for users (supports cron recurring tasks and one-time tasks)
|
||||
category: Task Scheduling
|
||||
---
|
||||
|
||||
# Schedule Job - Scheduled Task Management
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
---
|
||||
name: skill-creator
|
||||
description: Create new skills, modify and improve existing skills, and measure skill performance. Use when users want to create a skill from scratch, update or optimize an existing skill, run evals to test a skill, benchmark skill performance with variance analysis, or optimize a skill's description for better triggering accuracy.
|
||||
category: Developer Tools
|
||||
---
|
||||
|
||||
# Skill Creator
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
name: xlsx
|
||||
description: "Comprehensive spreadsheet creation, editing, and analysis with support for formulas, formatting, data analysis, and visualization. When Claude needs to work with spreadsheets (.xlsx, .xlsm, .csv, .tsv, etc) for: (1) Creating new spreadsheets with formulas and formatting, (2) Reading or analyzing data, (3) Modify existing spreadsheets while preserving formulas, (4) Data analysis and visualization in spreadsheets, or (5) Recalculating formulas"
|
||||
license: Proprietary. LICENSE.txt has complete terms
|
||||
category: Document Processing
|
||||
---
|
||||
|
||||
# Requirements for Outputs
|
||||
|
||||
86
skills/developing/ai-ppt-generator/SKILL.md
Normal file
86
skills/developing/ai-ppt-generator/SKILL.md
Normal file
@ -0,0 +1,86 @@
|
||||
---
|
||||
name: ai-ppt-generator
|
||||
description: Generate PPT with Baidu AI. Smart template selection based on content.
|
||||
metadata: { "openclaw": { "emoji": "📑", "requires": { "bins": ["python3"], "env":["BAIDU_API_KEY"]},"primaryEnv":"BAIDU_API_KEY" } }
|
||||
category: Document Processing
|
||||
---
|
||||
|
||||
# AI PPT Generator
|
||||
|
||||
Generate PPT using Baidu AI with intelligent template selection.
|
||||
|
||||
## Smart Workflow
|
||||
1. **User provides PPT topic**
|
||||
2. **Agent asks**: "Want to choose a template style?"
|
||||
3. **If yes** → Show styles from `ppt_theme_list.py` → User picks → Use `generate_ppt.py` with chosen `tpl_id` and real `style_id`
|
||||
4. **If no** → Use `random_ppt_theme.py` (auto-selects appropriate template based on topic content)
|
||||
|
||||
## Intelligent Template Selection
|
||||
`random_ppt_theme.py` analyzes the topic and suggests appropriate template:
|
||||
- **Business topics** → 企业商务 style
|
||||
- **Technology topics** → 未来科技 style
|
||||
- **Education topics** → 卡通手绘 style
|
||||
- **Creative topics** → 创意趣味 style
|
||||
- **Cultural topics** → 中国风 or 文化艺术 style
|
||||
- **Year-end reports** → 年终总结 style
|
||||
- **Minimalist design** → 扁平简约 style
|
||||
- **Artistic content** → 文艺清新 style
|
||||
|
||||
## Scripts
|
||||
- `scripts/ppt_theme_list.py` - List all available templates with style_id and tpl_id
|
||||
- `scripts/random_ppt_theme.py` - Smart template selection + generate PPT
|
||||
- `scripts/generate_ppt.py` - Generate PPT with specific template (uses real style_id and tpl_id from API)
|
||||
|
||||
## Key Features
|
||||
- **Smart categorization**: Analyzes topic content to suggest appropriate style
|
||||
- **Fallback logic**: If template not found, automatically uses random selection
|
||||
- **Complete parameters**: Properly passes both style_id and tpl_id to API
|
||||
|
||||
## Usage Examples
|
||||
```bash
|
||||
# List all templates with IDs
|
||||
python3 scripts/ppt_theme_list.py
|
||||
|
||||
# Smart automatic selection (recommended for most users)
|
||||
python3 scripts/random_ppt_theme.py --query "人工智能发展趋势报告"
|
||||
|
||||
# Specific template with proper style_id
|
||||
python3 scripts/generate_ppt.py --query "儿童英语课件" --tpl_id 106
|
||||
|
||||
# Specific template with auto-suggested category
|
||||
python3 scripts/random_ppt_theme.py --query "企业年度总结" --category "企业商务"
|
||||
```
|
||||
|
||||
## Agent Steps
|
||||
1. Get PPT topic from user
|
||||
2. Ask: "Want to choose a template style?"
|
||||
3. **If user says YES**:
|
||||
- Run `ppt_theme_list.py` to show available templates
|
||||
- User selects a template (note the tpl_id)
|
||||
- Run `generate_ppt.py --query "TOPIC" --tpl_id ID`
|
||||
4. **If user says NO**:
|
||||
- Run `random_ppt_theme.py --query "TOPIC"`
|
||||
- Script will auto-select appropriate template based on topic
|
||||
5. Set timeout to 300 seconds (PPT generation takes 2-5 minutes)
|
||||
6. Monitor output, wait for `is_end: true` to get final PPT URL
|
||||
|
||||
## Output Examples
|
||||
**During generation:**
|
||||
```json
|
||||
{"status": "PPT生成中", "run_time": 45}
|
||||
```
|
||||
|
||||
**Final result:**
|
||||
```json
|
||||
{
|
||||
"status": "PPT导出结束",
|
||||
"is_end": true,
|
||||
"data": {"ppt_url": "https://image0.bj.bcebos.com/...ppt"}
|
||||
}
|
||||
```
|
||||
|
||||
## Technical Notes
|
||||
- **API integration**: Fetches real style_id from Baidu API for each template
|
||||
- **Error handling**: If template not found, falls back to random selection
|
||||
- **Timeout**: Generation takes 2-5 minutes, set sufficient timeout
|
||||
- **Streaming**: Uses streaming API, wait for `is_end: true` before considering complete
|
||||
@ -8,5 +8,6 @@
|
||||
},
|
||||
"skills": [
|
||||
"./skills/catalog-search-agent"
|
||||
]
|
||||
],
|
||||
"category": "Data & Retrieval"
|
||||
}
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "ecommerce-storefront",
|
||||
"description": "Renders interactive product browsing, selection, and order confirmation UI for e-commerce scenarios.",
|
||||
"hooks": {
|
||||
"PrePrompt": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python hooks/pre_prompt.py"
|
||||
}
|
||||
]
|
||||
},
|
||||
"mcpServers": {
|
||||
"ecommerce_storefront": {
|
||||
"transport": "stdio",
|
||||
"command": "python",
|
||||
"args": [
|
||||
"./ecommerce_server.py",
|
||||
"{bot_id}"
|
||||
]
|
||||
}
|
||||
},
|
||||
"category": "Developer Tools"
|
||||
}
|
||||
233
skills/developing/ecommerce-storefront/apps/order-confirm.html
Normal file
233
skills/developing/ecommerce-storefront/apps/order-confirm.html
Normal file
@ -0,0 +1,233 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Order Confirmation</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f7; padding: 20px; }
|
||||
|
||||
.order-card { background: #fff; border-radius: 16px; border: 1px solid #e5e5ea; overflow: hidden; max-width: 480px; margin: 0 auto; }
|
||||
.order-header { padding: 20px 24px 16px; border-bottom: 1px solid #f0f0f0; }
|
||||
.order-title { font-size: 20px; font-weight: 700; color: #1d1d1f; }
|
||||
.order-id { font-size: 13px; color: #86868b; margin-top: 4px; }
|
||||
|
||||
.order-items { padding: 16px 24px; }
|
||||
.order-item { display: flex; align-items: center; padding: 12px 0; border-bottom: 1px solid #f5f5f7; }
|
||||
.order-item:last-child { border-bottom: none; }
|
||||
.item-img { width: 48px; height: 48px; border-radius: 10px; object-fit: cover; background: #f0f0f0; margin-right: 12px; flex-shrink: 0; }
|
||||
.item-img-placeholder { width: 48px; height: 48px; border-radius: 10px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex; align-items: center; justify-content: center; color: #fff; font-size: 18px; font-weight: 600; margin-right: 12px; flex-shrink: 0; }
|
||||
.item-info { flex: 1; min-width: 0; }
|
||||
.item-name { font-size: 15px; font-weight: 500; color: #1d1d1f; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.item-qty { font-size: 13px; color: #86868b; margin-top: 2px; }
|
||||
.item-price { font-size: 15px; font-weight: 600; color: #1d1d1f; flex-shrink: 0; margin-left: 12px; }
|
||||
|
||||
.order-summary { padding: 16px 24px; border-top: 1px solid #e5e5ea; background: #fafafa; }
|
||||
.summary-row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 14px; color: #6e6e73; }
|
||||
.summary-row.discount { color: #34c759; }
|
||||
.summary-row.total { padding-top: 12px; margin-top: 8px; border-top: 1px solid #e5e5ea;
|
||||
font-size: 18px; font-weight: 700; color: #1d1d1f; }
|
||||
|
||||
.payment-section { padding: 20px 24px; }
|
||||
.payment-qr { display: flex; flex-direction: column; align-items: center; padding: 16px 0; }
|
||||
.payment-qr img { width: 160px; height: 160px; border-radius: 8px; border: 1px solid #e5e5ea; }
|
||||
.payment-qr-hint { font-size: 13px; color: #86868b; margin-top: 8px; }
|
||||
|
||||
.btn-row { display: flex; gap: 10px; }
|
||||
.btn { flex: 1; padding: 14px; border: none; border-radius: 12px; font-size: 15px; font-weight: 600; cursor: pointer; transition: all 0.15s; }
|
||||
.btn-primary { background: #0071e3; color: #fff; }
|
||||
.btn-primary:hover { background: #0077ed; }
|
||||
.btn-primary:active { background: #005bb5; }
|
||||
.btn-secondary { background: #f5f5f7; color: #1d1d1f; border: 1px solid #d1d1d6; }
|
||||
.btn-secondary:hover { background: #e8e8ed; }
|
||||
.btn-link { background: #34c759; color: #fff; text-decoration: none; text-align: center; display: block; }
|
||||
.btn-link:hover { background: #2db84d; }
|
||||
|
||||
.success-overlay { display: none; position: absolute; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(255,255,255,0.95); border-radius: 16px;
|
||||
flex-direction: column; align-items: center; justify-content: center; }
|
||||
.success-overlay.show { display: flex; }
|
||||
.success-icon { font-size: 48px; margin-bottom: 12px; }
|
||||
.success-text { font-size: 18px; font-weight: 600; color: #1d1d1f; }
|
||||
.success-sub { font-size: 14px; color: #86868b; margin-top: 4px; }
|
||||
|
||||
.order-card-wrapper { position: relative; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="order-card-wrapper">
|
||||
<div class="order-card" id="order-card"></div>
|
||||
<div class="success-overlay" id="success-overlay">
|
||||
<div class="success-icon" id="success-icon"></div>
|
||||
<div class="success-text" id="success-text"></div>
|
||||
<div class="success-sub" id="success-sub"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
function esc(t) { var d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
|
||||
|
||||
function render(payload) {
|
||||
var card = document.getElementById('order-card');
|
||||
card.innerHTML = '';
|
||||
var order = payload.order || {};
|
||||
var payment = payload.payment || {};
|
||||
var currency = order.currency || '$';
|
||||
|
||||
// Header
|
||||
var header = document.createElement('div');
|
||||
header.className = 'order-header';
|
||||
header.innerHTML = '<div class="order-title">' + esc(payload.title || 'Order Confirmation') + '</div>'
|
||||
+ (order.order_id ? '<div class="order-id">Order #' + esc(order.order_id) + '</div>' : '');
|
||||
card.appendChild(header);
|
||||
|
||||
// Items
|
||||
var itemsDiv = document.createElement('div');
|
||||
itemsDiv.className = 'order-items';
|
||||
(order.items || []).forEach(function (item) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'order-item';
|
||||
|
||||
if (item.image) {
|
||||
var img = document.createElement('img');
|
||||
img.className = 'item-img';
|
||||
img.src = item.image;
|
||||
img.onerror = function () {
|
||||
var ph = document.createElement('div');
|
||||
ph.className = 'item-img-placeholder';
|
||||
ph.textContent = (item.name || 'I').charAt(0).toUpperCase();
|
||||
row.replaceChild(ph, img);
|
||||
};
|
||||
row.appendChild(img);
|
||||
} else {
|
||||
var ph = document.createElement('div');
|
||||
ph.className = 'item-img-placeholder';
|
||||
ph.textContent = (item.name || 'I').charAt(0).toUpperCase();
|
||||
row.appendChild(ph);
|
||||
}
|
||||
|
||||
var info = document.createElement('div');
|
||||
info.className = 'item-info';
|
||||
info.innerHTML = '<div class="item-name">' + esc(item.name || '') + '</div>'
|
||||
+ '<div class="item-qty">x' + (item.quantity || 1) + '</div>';
|
||||
row.appendChild(info);
|
||||
|
||||
var price = document.createElement('div');
|
||||
price.className = 'item-price';
|
||||
price.textContent = currency + ((item.price || 0) * (item.quantity || 1)).toFixed(2);
|
||||
row.appendChild(price);
|
||||
|
||||
itemsDiv.appendChild(row);
|
||||
});
|
||||
card.appendChild(itemsDiv);
|
||||
|
||||
// Summary
|
||||
var summary = document.createElement('div');
|
||||
summary.className = 'order-summary';
|
||||
if (order.subtotal !== undefined) {
|
||||
summary.innerHTML += '<div class="summary-row"><span>Subtotal</span><span>' + currency + (order.subtotal || 0).toFixed(2) + '</span></div>';
|
||||
}
|
||||
if (order.tax) {
|
||||
summary.innerHTML += '<div class="summary-row"><span>Tax</span><span>' + currency + order.tax.toFixed(2) + '</span></div>';
|
||||
}
|
||||
if (order.discount) {
|
||||
summary.innerHTML += '<div class="summary-row discount"><span>Discount</span><span>-' + currency + order.discount.toFixed(2) + '</span></div>';
|
||||
}
|
||||
summary.innerHTML += '<div class="summary-row total"><span>Total</span><span>' + currency + (order.total || 0).toFixed(2) + '</span></div>';
|
||||
card.appendChild(summary);
|
||||
|
||||
// Payment
|
||||
var payDiv = document.createElement('div');
|
||||
payDiv.className = 'payment-section';
|
||||
|
||||
if (payment.method === 'qrcode' && payment.qrcode_url) {
|
||||
var qrDiv = document.createElement('div');
|
||||
qrDiv.className = 'payment-qr';
|
||||
qrDiv.innerHTML = '<img src="' + esc(payment.qrcode_url) + '" alt="Payment QR Code">'
|
||||
+ '<div class="payment-qr-hint">Scan to pay</div>';
|
||||
payDiv.appendChild(qrDiv);
|
||||
}
|
||||
|
||||
var btnRow = document.createElement('div');
|
||||
btnRow.className = 'btn-row';
|
||||
|
||||
// Cancel button
|
||||
var cancelBtn = document.createElement('button');
|
||||
cancelBtn.className = 'btn btn-secondary';
|
||||
cancelBtn.textContent = 'Cancel';
|
||||
cancelBtn.addEventListener('click', function () {
|
||||
showOverlay('cancelled');
|
||||
window.parent.postMessage({
|
||||
type: 'mcp-app-response',
|
||||
payload: { action: 'cancel', order_id: order.order_id || '' }
|
||||
}, '*');
|
||||
});
|
||||
btnRow.appendChild(cancelBtn);
|
||||
|
||||
if (payment.method === 'link' && payment.payment_url) {
|
||||
var linkBtn = document.createElement('a');
|
||||
linkBtn.className = 'btn btn-link';
|
||||
linkBtn.href = payment.payment_url;
|
||||
linkBtn.target = '_blank';
|
||||
linkBtn.rel = 'noopener';
|
||||
linkBtn.textContent = payment.button_text || 'Pay Now';
|
||||
linkBtn.addEventListener('click', function () {
|
||||
setTimeout(function () {
|
||||
showOverlay('confirmed');
|
||||
window.parent.postMessage({
|
||||
type: 'mcp-app-response',
|
||||
payload: { action: 'confirm', order_id: order.order_id || '' }
|
||||
}, '*');
|
||||
}, 500);
|
||||
});
|
||||
btnRow.appendChild(linkBtn);
|
||||
} else {
|
||||
var confirmBtn = document.createElement('button');
|
||||
confirmBtn.className = 'btn btn-primary';
|
||||
confirmBtn.textContent = payment.button_text || 'Confirm Payment';
|
||||
confirmBtn.addEventListener('click', function () {
|
||||
showOverlay('confirmed');
|
||||
window.parent.postMessage({
|
||||
type: 'mcp-app-response',
|
||||
payload: { action: 'confirm', order_id: order.order_id || '' }
|
||||
}, '*');
|
||||
});
|
||||
btnRow.appendChild(confirmBtn);
|
||||
}
|
||||
|
||||
payDiv.appendChild(btnRow);
|
||||
card.appendChild(payDiv);
|
||||
}
|
||||
|
||||
function showOverlay(type) {
|
||||
var overlay = document.getElementById('success-overlay');
|
||||
var icon = document.getElementById('success-icon');
|
||||
var text = document.getElementById('success-text');
|
||||
var sub = document.getElementById('success-sub');
|
||||
if (type === 'confirmed') {
|
||||
icon.textContent = '\u2705';
|
||||
text.textContent = 'Payment Confirmed';
|
||||
sub.textContent = 'Your order has been placed successfully!';
|
||||
} else {
|
||||
icon.textContent = '\u274C';
|
||||
text.textContent = 'Order Cancelled';
|
||||
sub.textContent = 'Your order has been cancelled.';
|
||||
}
|
||||
overlay.classList.add('show');
|
||||
}
|
||||
|
||||
window.addEventListener('message', function (event) {
|
||||
var msg = event.data;
|
||||
if (msg && msg.type === 'mcp-app-data') {
|
||||
document.getElementById('success-overlay').classList.remove('show');
|
||||
render(msg.payload);
|
||||
}
|
||||
});
|
||||
|
||||
window.parent.postMessage({ type: 'mcp-app-ready' }, '*');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
288
skills/developing/ecommerce-storefront/apps/product-list.html
Normal file
288
skills/developing/ecommerce-storefront/apps/product-list.html
Normal file
@ -0,0 +1,288 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Product List</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f7; padding: 20px; }
|
||||
h1 { font-size: 22px; font-weight: 700; color: #1d1d1f; margin-bottom: 20px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
|
||||
|
||||
.card { background: #fff; border-radius: 16px; overflow: hidden; border: 1px solid #e5e5ea;
|
||||
transition: box-shadow 0.2s, transform 0.2s; display: flex; flex-direction: column; }
|
||||
.card:hover { box-shadow: 0 8px 24px rgba(0,0,0,0.08); transform: translateY(-2px); }
|
||||
|
||||
.card-img { width: 100%; height: 180px; object-fit: cover; background: #f0f0f0; }
|
||||
.card-img-placeholder { width: 100%; height: 180px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex; align-items: center; justify-content: center; color: #fff; font-size: 40px; }
|
||||
|
||||
.card-body { padding: 16px; flex: 1; display: flex; flex-direction: column; }
|
||||
.card-tags { display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
|
||||
.tag { padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
|
||||
.tag-hot { background: #fee2e2; color: #dc2626; }
|
||||
.tag-new { background: #dbeafe; color: #2563eb; }
|
||||
.tag-sale { background: #fef3c7; color: #d97706; }
|
||||
.tag-default { background: #f1f5f9; color: #475569; }
|
||||
|
||||
.card-name { font-size: 17px; font-weight: 600; color: #1d1d1f; margin-bottom: 4px; }
|
||||
.card-desc { font-size: 13px; color: #86868b; margin-bottom: 12px; line-height: 1.4; }
|
||||
.card-price { font-size: 22px; font-weight: 700; color: #0071e3; margin-bottom: 12px; }
|
||||
|
||||
.spec-group { margin-bottom: 10px; }
|
||||
.spec-label { font-size: 12px; font-weight: 600; color: #6e6e73; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.spec-options { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.spec-btn { padding: 6px 12px; border: 1.5px solid #d1d1d6; border-radius: 8px; background: #fff;
|
||||
cursor: pointer; font-size: 13px; color: #1d1d1f; transition: all 0.15s; white-space: nowrap; }
|
||||
.spec-btn:hover { border-color: #0071e3; color: #0071e3; }
|
||||
.spec-btn.selected { border-color: #0071e3; background: #0071e3; color: #fff; }
|
||||
.spec-btn .delta { font-size: 11px; color: #86868b; margin-left: 4px; }
|
||||
.spec-btn.selected .delta { color: rgba(255,255,255,0.8); }
|
||||
|
||||
.quantity-row { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
|
||||
.quantity-label { font-size: 12px; font-weight: 600; color: #6e6e73; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.quantity-ctrl { display: flex; align-items: center; gap: 0; }
|
||||
.qty-btn { width: 32px; height: 32px; border: 1.5px solid #d1d1d6; background: #fff; cursor: pointer;
|
||||
font-size: 16px; font-weight: 600; color: #1d1d1f; display: flex; align-items: center; justify-content: center; }
|
||||
.qty-btn:first-child { border-radius: 8px 0 0 8px; }
|
||||
.qty-btn:last-child { border-radius: 0 8px 8px 0; }
|
||||
.qty-btn:hover { background: #f5f5f7; }
|
||||
.qty-val { width: 40px; height: 32px; border-top: 1.5px solid #d1d1d6; border-bottom: 1.5px solid #d1d1d6;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 600; }
|
||||
|
||||
.spacer { flex: 1; }
|
||||
.add-btn { width: 100%; padding: 12px; border: none; border-radius: 12px; background: #0071e3;
|
||||
color: #fff; font-size: 15px; font-weight: 600; cursor: pointer; transition: background 0.15s; }
|
||||
.add-btn:hover { background: #0077ed; }
|
||||
.add-btn:active { background: #005bb5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 id="title"></h1>
|
||||
<div class="grid" id="grid"></div>
|
||||
<script>
|
||||
(function () {
|
||||
function esc(t) { var d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
|
||||
|
||||
function getTagClass(tag) {
|
||||
var t = (tag || '').toLowerCase();
|
||||
if (t === 'hot' || t === 'popular') return 'tag-hot';
|
||||
if (t === 'new') return 'tag-new';
|
||||
if (t === 'sale' || t === 'discount') return 'tag-sale';
|
||||
return 'tag-default';
|
||||
}
|
||||
|
||||
function render(payload) {
|
||||
document.getElementById('title').textContent = payload.title || 'Products';
|
||||
var grid = document.getElementById('grid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
(payload.products || []).forEach(function (p) {
|
||||
var card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
|
||||
// State
|
||||
var state = { specs: {}, quantity: 1 };
|
||||
var currency = p.currency || '$';
|
||||
|
||||
// Initialize default spec selections (first option of each group)
|
||||
(p.specs || []).forEach(function (sg) {
|
||||
if (sg.options && sg.options.length > 0) {
|
||||
state.specs[sg.label] = sg.options[0];
|
||||
}
|
||||
});
|
||||
|
||||
function calcPrice() {
|
||||
var total = p.price || 0;
|
||||
Object.keys(state.specs).forEach(function (k) {
|
||||
total += (state.specs[k].price_delta || 0);
|
||||
});
|
||||
return total;
|
||||
}
|
||||
|
||||
// Image
|
||||
if (p.image) {
|
||||
var img = document.createElement('img');
|
||||
img.className = 'card-img';
|
||||
img.src = p.image;
|
||||
img.alt = p.name || '';
|
||||
img.onerror = function () {
|
||||
var ph = document.createElement('div');
|
||||
ph.className = 'card-img-placeholder';
|
||||
ph.textContent = (p.name || 'P').charAt(0).toUpperCase();
|
||||
card.replaceChild(ph, img);
|
||||
};
|
||||
card.appendChild(img);
|
||||
} else {
|
||||
var ph = document.createElement('div');
|
||||
ph.className = 'card-img-placeholder';
|
||||
ph.textContent = (p.name || 'P').charAt(0).toUpperCase();
|
||||
card.appendChild(ph);
|
||||
}
|
||||
|
||||
var body = document.createElement('div');
|
||||
body.className = 'card-body';
|
||||
|
||||
// Tags
|
||||
if (p.tags && p.tags.length > 0) {
|
||||
var tagsDiv = document.createElement('div');
|
||||
tagsDiv.className = 'card-tags';
|
||||
p.tags.forEach(function (t) {
|
||||
var span = document.createElement('span');
|
||||
span.className = 'tag ' + getTagClass(t);
|
||||
span.textContent = t;
|
||||
tagsDiv.appendChild(span);
|
||||
});
|
||||
body.appendChild(tagsDiv);
|
||||
}
|
||||
|
||||
// Name
|
||||
var name = document.createElement('div');
|
||||
name.className = 'card-name';
|
||||
name.textContent = p.name || '';
|
||||
body.appendChild(name);
|
||||
|
||||
// Description
|
||||
if (p.description) {
|
||||
var desc = document.createElement('div');
|
||||
desc.className = 'card-desc';
|
||||
desc.textContent = p.description;
|
||||
body.appendChild(desc);
|
||||
}
|
||||
|
||||
// Price
|
||||
var priceEl = document.createElement('div');
|
||||
priceEl.className = 'card-price';
|
||||
priceEl.textContent = currency + calcPrice().toFixed(2);
|
||||
body.appendChild(priceEl);
|
||||
|
||||
// Specs
|
||||
(p.specs || []).forEach(function (sg) {
|
||||
var group = document.createElement('div');
|
||||
group.className = 'spec-group';
|
||||
|
||||
var label = document.createElement('div');
|
||||
label.className = 'spec-label';
|
||||
label.textContent = sg.label;
|
||||
group.appendChild(label);
|
||||
|
||||
var optionsDiv = document.createElement('div');
|
||||
optionsDiv.className = 'spec-options';
|
||||
|
||||
(sg.options || []).forEach(function (opt, idx) {
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'spec-btn' + (idx === 0 ? ' selected' : '');
|
||||
var deltaText = '';
|
||||
if (opt.price_delta && opt.price_delta !== 0) {
|
||||
deltaText = (opt.price_delta > 0 ? '+' : '') + currency + Math.abs(opt.price_delta).toFixed(2);
|
||||
}
|
||||
btn.innerHTML = esc(opt.name) + (deltaText ? ' <span class="delta">' + deltaText + '</span>' : '');
|
||||
|
||||
btn.addEventListener('click', function () {
|
||||
state.specs[sg.label] = opt;
|
||||
optionsDiv.querySelectorAll('.spec-btn').forEach(function (b) { b.classList.remove('selected'); });
|
||||
btn.classList.add('selected');
|
||||
priceEl.textContent = currency + calcPrice().toFixed(2);
|
||||
});
|
||||
|
||||
optionsDiv.appendChild(btn);
|
||||
});
|
||||
|
||||
group.appendChild(optionsDiv);
|
||||
body.appendChild(group);
|
||||
});
|
||||
|
||||
// Quantity
|
||||
var qtyRow = document.createElement('div');
|
||||
qtyRow.className = 'quantity-row';
|
||||
var qtyLabel = document.createElement('div');
|
||||
qtyLabel.className = 'quantity-label';
|
||||
qtyLabel.textContent = 'Quantity';
|
||||
qtyRow.appendChild(qtyLabel);
|
||||
|
||||
var qtyCtrl = document.createElement('div');
|
||||
qtyCtrl.className = 'quantity-ctrl';
|
||||
|
||||
var minusBtn = document.createElement('button');
|
||||
minusBtn.className = 'qty-btn';
|
||||
minusBtn.textContent = '-';
|
||||
|
||||
var qtyVal = document.createElement('div');
|
||||
qtyVal.className = 'qty-val';
|
||||
qtyVal.textContent = '1';
|
||||
|
||||
var plusBtn = document.createElement('button');
|
||||
plusBtn.className = 'qty-btn';
|
||||
plusBtn.textContent = '+';
|
||||
|
||||
minusBtn.addEventListener('click', function () {
|
||||
if (state.quantity > 1) {
|
||||
state.quantity--;
|
||||
qtyVal.textContent = state.quantity;
|
||||
}
|
||||
});
|
||||
plusBtn.addEventListener('click', function () {
|
||||
if (state.quantity < 99) {
|
||||
state.quantity++;
|
||||
qtyVal.textContent = state.quantity;
|
||||
}
|
||||
});
|
||||
|
||||
qtyCtrl.appendChild(minusBtn);
|
||||
qtyCtrl.appendChild(qtyVal);
|
||||
qtyCtrl.appendChild(plusBtn);
|
||||
qtyRow.appendChild(qtyCtrl);
|
||||
body.appendChild(qtyRow);
|
||||
|
||||
// Spacer
|
||||
var spacer = document.createElement('div');
|
||||
spacer.className = 'spacer';
|
||||
body.appendChild(spacer);
|
||||
|
||||
// Add to Cart button
|
||||
var addBtn = document.createElement('button');
|
||||
addBtn.className = 'add-btn';
|
||||
addBtn.textContent = 'Add to Cart';
|
||||
addBtn.addEventListener('click', function () {
|
||||
var selectedSpecs = {};
|
||||
Object.keys(state.specs).forEach(function (k) {
|
||||
selectedSpecs[k] = state.specs[k].name;
|
||||
});
|
||||
window.parent.postMessage({
|
||||
type: 'mcp-app-response',
|
||||
payload: {
|
||||
product_id: p.id,
|
||||
product_name: p.name,
|
||||
selected_specs: selectedSpecs,
|
||||
final_price: calcPrice(),
|
||||
quantity: state.quantity,
|
||||
currency: currency
|
||||
}
|
||||
}, '*');
|
||||
addBtn.textContent = 'Added!';
|
||||
addBtn.style.background = '#34c759';
|
||||
setTimeout(function () {
|
||||
addBtn.textContent = 'Add to Cart';
|
||||
addBtn.style.background = '#0071e3';
|
||||
}, 1500);
|
||||
});
|
||||
body.appendChild(addBtn);
|
||||
|
||||
card.appendChild(body);
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('message', function (event) {
|
||||
var msg = event.data;
|
||||
if (msg && msg.type === 'mcp-app-data') {
|
||||
render(msg.payload);
|
||||
}
|
||||
});
|
||||
|
||||
window.parent.postMessage({ type: 'mcp-app-ready' }, '*');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
213
skills/developing/ecommerce-storefront/ecommerce_server.py
Normal file
213
skills/developing/ecommerce-storefront/ecommerce_server.py
Normal file
@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
E-Commerce Storefront MCP Server - standard MCP Apps protocol (SEP-1865).
|
||||
|
||||
- tools/call returns structured data only (no HTML)
|
||||
- resources/read returns static HTML App files
|
||||
- Host renders HTML App in iframe, passes tool data via postMessage
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict
|
||||
|
||||
from mcp_common import (
|
||||
create_error_response,
|
||||
create_ping_response,
|
||||
create_tools_list_response,
|
||||
load_tools_from_json,
|
||||
handle_mcp_streaming,
|
||||
)
|
||||
|
||||
RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"
|
||||
APPS_DIR = os.path.join(os.path.dirname(__file__), "apps")
|
||||
|
||||
# Resource URI -> static HTML App file mapping
|
||||
RESOURCE_MAP = {
|
||||
"ui://ecommerce-storefront/product-list": "product-list.html",
|
||||
"ui://ecommerce-storefront/order-confirm": "order-confirm.html",
|
||||
}
|
||||
|
||||
RESOURCE_DEFINITIONS = [
|
||||
{
|
||||
"uri": "ui://ecommerce-storefront/product-list",
|
||||
"name": "product-list",
|
||||
"title": "Product List",
|
||||
"description": "Interactive product cards with spec selection",
|
||||
"mimeType": RESOURCE_MIME_TYPE,
|
||||
},
|
||||
{
|
||||
"uri": "ui://ecommerce-storefront/order-confirm",
|
||||
"name": "order-confirm",
|
||||
"title": "Order Confirmation",
|
||||
"description": "Order summary with payment options",
|
||||
"mimeType": RESOURCE_MIME_TYPE,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _load_app_html(uri: str) -> str:
|
||||
"""Load static HTML App file for the given resource URI."""
|
||||
filename = RESOURCE_MAP.get(uri)
|
||||
if not filename:
|
||||
raise ValueError(f"Unknown resource URI: {uri}")
|
||||
filepath = os.path.join(APPS_DIR, filename)
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _create_app_response(resource_uri: str, data: Dict[str, Any],
|
||||
width: str = "100%", height: str = "auto") -> Dict[str, Any]:
|
||||
"""Create a tool result for MCP Apps protocol."""
|
||||
app_json = json.dumps({
|
||||
"type": "app",
|
||||
"resourceUri": resource_uri,
|
||||
"data": data,
|
||||
"_meta": {
|
||||
"mcpui.dev/ui-preferred-frame-size": [width, height],
|
||||
},
|
||||
}, ensure_ascii=False)
|
||||
return {"content": [{"type": "text", "text": app_json}]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _handle_render_product_list(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||
title = arguments.get("title", "Products")
|
||||
products = arguments.get("products", [])
|
||||
if not products:
|
||||
raise ValueError("Missing required parameter: products")
|
||||
return _create_app_response(
|
||||
"ui://ecommerce-storefront/product-list",
|
||||
{"title": title, "products": products},
|
||||
"100%", "auto",
|
||||
)
|
||||
|
||||
|
||||
def _handle_render_order_confirm(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||
title = arguments.get("title", "Order Confirmation")
|
||||
order = arguments.get("order")
|
||||
payment = arguments.get("payment")
|
||||
if not order:
|
||||
raise ValueError("Missing required parameter: order")
|
||||
if not payment:
|
||||
raise ValueError("Missing required parameter: payment")
|
||||
if not order.get("items"):
|
||||
raise ValueError("Missing required parameter: order.items")
|
||||
return _create_app_response(
|
||||
"ui://ecommerce-storefront/order-confirm",
|
||||
{"title": title, "order": order, "payment": payment},
|
||||
"100%", "auto",
|
||||
)
|
||||
|
||||
|
||||
TOOL_HANDLERS = {
|
||||
"render_product_list": _handle_render_product_list,
|
||||
"render_order_confirm": _handle_render_order_confirm,
|
||||
}
|
||||
|
||||
|
||||
async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Handle an MCP request."""
|
||||
try:
|
||||
method = request.get("method")
|
||||
params = request.get("params", {})
|
||||
request_id = request.get("id")
|
||||
|
||||
if method == "initialize":
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {
|
||||
"tools": {},
|
||||
"resources": {},
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": "ecommerce-storefront",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
elif method == "ping":
|
||||
return create_ping_response(request_id)
|
||||
|
||||
elif method == "tools/list":
|
||||
tools = load_tools_from_json("ecommerce_tools.json")
|
||||
if not tools:
|
||||
tools = [
|
||||
{
|
||||
"name": name,
|
||||
"description": f"Render {name.replace('render_', '')}",
|
||||
"inputSchema": {"type": "object", "properties": {}, "required": []},
|
||||
"_meta": {"ui": {"resourceUri": uri}},
|
||||
}
|
||||
for name, uri in [
|
||||
("render_product_list", "ui://ecommerce-storefront/product-list"),
|
||||
("render_order_confirm", "ui://ecommerce-storefront/order-confirm"),
|
||||
]
|
||||
]
|
||||
return create_tools_list_response(request_id, tools)
|
||||
|
||||
elif method == "resources/list":
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {"resources": RESOURCE_DEFINITIONS},
|
||||
}
|
||||
|
||||
elif method == "resources/read":
|
||||
uri = params.get("uri", "")
|
||||
try:
|
||||
html = _load_app_html(uri)
|
||||
except (ValueError, FileNotFoundError) as e:
|
||||
return create_error_response(request_id, -32602, str(e))
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"contents": [
|
||||
{
|
||||
"uri": uri,
|
||||
"mimeType": RESOURCE_MIME_TYPE,
|
||||
"text": html,
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
elif method == "tools/call":
|
||||
tool_name = params.get("name")
|
||||
arguments = params.get("arguments", {})
|
||||
|
||||
handler = TOOL_HANDLERS.get(tool_name)
|
||||
if not handler:
|
||||
return create_error_response(request_id, -32601, f"Unknown tool: {tool_name}")
|
||||
|
||||
try:
|
||||
result = handler(arguments)
|
||||
except ValueError as e:
|
||||
return create_error_response(request_id, -32602, str(e))
|
||||
|
||||
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
||||
|
||||
else:
|
||||
return create_error_response(request_id, -32601, f"Unknown method: {method}")
|
||||
|
||||
except Exception as e:
|
||||
return create_error_response(
|
||||
request.get("id"), -32603, f"Internal error: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
await handle_mcp_streaming(handle_request)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
125
skills/developing/ecommerce-storefront/ecommerce_tools.json
Normal file
125
skills/developing/ecommerce-storefront/ecommerce_tools.json
Normal file
@ -0,0 +1,125 @@
|
||||
[
|
||||
{
|
||||
"name": "render_product_list",
|
||||
"description": "Render an interactive product card list. Users can browse products, select specifications (size/flavor/etc.), and add items to cart. Returns the user's selection via mcp-app-response.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Title displayed at the top of the product list"
|
||||
},
|
||||
"products": {
|
||||
"type": "array",
|
||||
"description": "Array of product objects to display as cards",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string", "description": "Unique product identifier" },
|
||||
"name": { "type": "string", "description": "Product name" },
|
||||
"description": { "type": "string", "description": "Short product description" },
|
||||
"image": { "type": "string", "description": "Product image URL" },
|
||||
"price": { "type": "number", "description": "Base price" },
|
||||
"currency": { "type": "string", "description": "Currency symbol, default: '$'", "default": "$" },
|
||||
"specs": {
|
||||
"type": "array",
|
||||
"description": "Available specification groups (e.g. size, flavor)",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": { "type": "string", "description": "Spec group label, e.g. 'Size'" },
|
||||
"options": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string", "description": "Option name, e.g. 'Large'" },
|
||||
"price_delta": { "type": "number", "description": "Price adjustment, default 0", "default": 0 }
|
||||
},
|
||||
"required": ["name"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["label", "options"]
|
||||
}
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Optional tags like 'Hot', 'New', 'Sale'"
|
||||
}
|
||||
},
|
||||
"required": ["id", "name", "price"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["title", "products"]
|
||||
},
|
||||
"_meta": {
|
||||
"ui": {
|
||||
"resourceUri": "ui://ecommerce-storefront/product-list"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "render_order_confirm",
|
||||
"description": "Render an order confirmation page with order details, total price, and a payment action button or QR code. Returns 'confirmed' or 'cancelled' via mcp-app-response.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Page title, e.g. 'Order Confirmation'"
|
||||
},
|
||||
"order": {
|
||||
"type": "object",
|
||||
"description": "Order details",
|
||||
"properties": {
|
||||
"order_id": { "type": "string", "description": "Order ID" },
|
||||
"items": {
|
||||
"type": "array",
|
||||
"description": "Ordered items",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string", "description": "Product name with specs" },
|
||||
"quantity": { "type": "integer", "description": "Quantity", "default": 1 },
|
||||
"price": { "type": "number", "description": "Unit price" },
|
||||
"image": { "type": "string", "description": "Product image URL" }
|
||||
},
|
||||
"required": ["name", "price"]
|
||||
}
|
||||
},
|
||||
"subtotal": { "type": "number", "description": "Subtotal before tax/fees" },
|
||||
"tax": { "type": "number", "description": "Tax amount", "default": 0 },
|
||||
"discount": { "type": "number", "description": "Discount amount", "default": 0 },
|
||||
"total": { "type": "number", "description": "Final total" },
|
||||
"currency": { "type": "string", "description": "Currency symbol", "default": "$" }
|
||||
},
|
||||
"required": ["order_id", "items", "total"]
|
||||
},
|
||||
"payment": {
|
||||
"type": "object",
|
||||
"description": "Payment configuration",
|
||||
"properties": {
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["button", "qrcode", "link"],
|
||||
"description": "Payment UI type: button (confirm button), qrcode (QR code image), link (external payment URL)"
|
||||
},
|
||||
"qrcode_url": { "type": "string", "description": "QR code image URL (when method=qrcode)" },
|
||||
"payment_url": { "type": "string", "description": "External payment URL (when method=link)" },
|
||||
"button_text": { "type": "string", "description": "Payment button text, default: 'Confirm Payment'", "default": "Confirm Payment" }
|
||||
},
|
||||
"required": ["method"]
|
||||
}
|
||||
},
|
||||
"required": ["title", "order", "payment"]
|
||||
},
|
||||
"_meta": {
|
||||
"ui": {
|
||||
"resourceUri": "ui://ecommerce-storefront/order-confirm"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
102
skills/developing/ecommerce-storefront/hooks/ecommerce_guide.md
Normal file
102
skills/developing/ecommerce-storefront/hooks/ecommerce_guide.md
Normal file
@ -0,0 +1,102 @@
|
||||
## E-Commerce Storefront Tools Usage Guide
|
||||
|
||||
Two tools are available for e-commerce product browsing and ordering:
|
||||
- `render_product_list` — Display interactive product cards with spec selection
|
||||
- `render_order_confirm` — Display order confirmation with payment options
|
||||
|
||||
---
|
||||
|
||||
### 1. render_product_list
|
||||
|
||||
When to use: User wants to browse, search, or buy products. Show product cards with images, prices, and selectable specs (size, flavor, color, etc.).
|
||||
|
||||
The user can select specs and click "Add to Cart" on any product. The selection is returned as an mcp-app-response containing: `{ product_id, product_name, selected_specs: {...}, final_price, quantity }`.
|
||||
|
||||
```
|
||||
render_product_list(
|
||||
title="Coffee Menu",
|
||||
products=[
|
||||
{
|
||||
"id": "latte-001",
|
||||
"name": "Caffe Latte",
|
||||
"description": "Rich espresso with steamed milk",
|
||||
"image": "https://images.unsplash.com/photo-1572442388796-11668a67e53d?w=300&h=200&fit=crop",
|
||||
"price": 4.50,
|
||||
"currency": "$",
|
||||
"specs": [
|
||||
{
|
||||
"label": "Size",
|
||||
"options": [
|
||||
{"name": "Small", "price_delta": 0},
|
||||
{"name": "Medium", "price_delta": 0.5},
|
||||
{"name": "Large", "price_delta": 1.0}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Milk",
|
||||
"options": [
|
||||
{"name": "Whole Milk", "price_delta": 0},
|
||||
{"name": "Oat Milk", "price_delta": 0.6},
|
||||
{"name": "Almond Milk", "price_delta": 0.6}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["Hot", "Popular"]
|
||||
}
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
After receiving the user's selection, proceed to generate an order and call `render_order_confirm`.
|
||||
|
||||
---
|
||||
|
||||
### 2. render_order_confirm
|
||||
|
||||
When to use: After the user selects a product and you have generated an order.
|
||||
|
||||
Payment method options:
|
||||
- `"button"` — Simple confirm/cancel buttons (default)
|
||||
- `"qrcode"` — Show a QR code image for mobile payment
|
||||
- `"link"` — Provide an external payment URL
|
||||
|
||||
```
|
||||
render_order_confirm(
|
||||
title="Order Confirmation",
|
||||
order={
|
||||
"order_id": "ORD-20260522-001",
|
||||
"items": [
|
||||
{"name": "Caffe Latte (Large, Oat Milk)", "quantity": 1, "price": 6.10}
|
||||
],
|
||||
"subtotal": 6.10,
|
||||
"tax": 0.49,
|
||||
"total": 6.59,
|
||||
"currency": "$"
|
||||
},
|
||||
payment={
|
||||
"method": "button",
|
||||
"button_text": "Confirm Payment"
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
The user response will be: `{ action: "confirm", order_id }` or `{ action: "cancel", order_id }`.
|
||||
|
||||
---
|
||||
|
||||
### Workflow
|
||||
|
||||
The typical e-commerce flow is:
|
||||
1. User asks to buy something → call `render_product_list`
|
||||
2. User selects a product → receive mcp-app-response with selection details
|
||||
3. Generate order from selection → call `render_order_confirm`
|
||||
4. User confirms or cancels → receive mcp-app-response with action
|
||||
5. If confirmed → process payment / show success message
|
||||
6. If cancelled → ask user what they'd like to do next
|
||||
|
||||
### Tips
|
||||
- Always include product images when available for better visual experience
|
||||
- Use tags like "Hot", "New", "Sale" to highlight special products
|
||||
- Keep product descriptions short (under 50 characters)
|
||||
- Generate a unique order_id for each order
|
||||
- Include tax/discount breakdowns when applicable for transparency
|
||||
@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PrePrompt hook - inject ecommerce tool guide into system prompt."""
|
||||
import os
|
||||
|
||||
guide_path = os.path.join(os.path.dirname(__file__), "ecommerce_guide.md")
|
||||
if os.path.exists(guide_path):
|
||||
with open(guide_path, "r", encoding="utf-8") as f:
|
||||
print(f.read())
|
||||
252
skills/developing/ecommerce-storefront/mcp_common.py
Normal file
252
skills/developing/ecommerce-storefront/mcp_common.py
Normal file
@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared utility functions for the MCP server.
|
||||
Provides common functionality for path handling, file validation, and request processing.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
import re
|
||||
|
||||
def get_allowed_directory():
|
||||
"""Get the directory that is allowed to be accessed."""
|
||||
# Prefer dataset_dir passed through command-line arguments.
|
||||
if len(sys.argv) > 1:
|
||||
dataset_dir = sys.argv[1]
|
||||
return os.path.abspath(dataset_dir)
|
||||
|
||||
# Read the project data directory from the environment variable.
|
||||
project_dir = os.getenv("PROJECT_DATA_DIR", "./projects/data")
|
||||
return os.path.abspath(project_dir)
|
||||
|
||||
|
||||
def resolve_file_path(file_path: str, default_subfolder: str = "default") -> str:
|
||||
"""
|
||||
Resolve a file path, supporting both folder/document.txt and document.txt formats.
|
||||
|
||||
Args:
|
||||
file_path: Input file path.
|
||||
default_subfolder: Default subfolder name to use when only a filename is provided.
|
||||
|
||||
Returns:
|
||||
The resolved full file path.
|
||||
"""
|
||||
# If the path contains a folder separator, use it directly.
|
||||
if '/' in file_path or '\\' in file_path:
|
||||
clean_path = file_path.replace('\\', '/')
|
||||
|
||||
# Remove the projects/ prefix if it exists.
|
||||
if clean_path.startswith('projects/'):
|
||||
clean_path = clean_path[9:] # Remove the 'projects/' prefix.
|
||||
elif clean_path.startswith('./projects/'):
|
||||
clean_path = clean_path[11:] # Remove the './projects/' prefix.
|
||||
else:
|
||||
# If only a filename is provided, add the default subfolder.
|
||||
clean_path = f"{default_subfolder}/{file_path}"
|
||||
|
||||
# Get the allowed directory.
|
||||
project_data_dir = get_allowed_directory()
|
||||
|
||||
# Try to locate the file directly under the project directory.
|
||||
full_path = os.path.join(project_data_dir, clean_path.lstrip('./'))
|
||||
if os.path.exists(full_path):
|
||||
return full_path
|
||||
|
||||
# If the direct path does not exist, try a recursive search.
|
||||
found = find_file_in_project(clean_path, project_data_dir)
|
||||
if found:
|
||||
return found
|
||||
|
||||
# If this is a bare filename and it was not found under the default subfolder,
|
||||
# try looking in the project root.
|
||||
if '/' not in file_path and '\\' not in file_path:
|
||||
root_path = os.path.join(project_data_dir, file_path)
|
||||
if os.path.exists(root_path):
|
||||
return root_path
|
||||
|
||||
raise FileNotFoundError(f"File not found: {file_path} (searched in {project_data_dir})")
|
||||
|
||||
|
||||
def find_file_in_project(filename: str, project_dir: str) -> Optional[str]:
|
||||
"""Recursively search for a file inside the project directory."""
|
||||
# If filename includes a path, only search within the specified path.
|
||||
if '/' in filename:
|
||||
parts = filename.split('/')
|
||||
target_file = parts[-1]
|
||||
search_dir = os.path.join(project_dir, *parts[:-1])
|
||||
|
||||
if os.path.exists(search_dir):
|
||||
target_path = os.path.join(search_dir, target_file)
|
||||
if os.path.exists(target_path):
|
||||
return target_path
|
||||
else:
|
||||
# For a bare filename, recursively search the whole project directory.
|
||||
for root, dirs, files in os.walk(project_dir):
|
||||
if filename in files:
|
||||
return os.path.join(root, filename)
|
||||
return None
|
||||
|
||||
|
||||
def load_tools_from_json(tools_file_name: str) -> List[Dict[str, Any]]:
|
||||
"""Load tool definitions from a JSON file."""
|
||||
try:
|
||||
tools_file = os.path.join(os.path.dirname(__file__), tools_file_name)
|
||||
if os.path.exists(tools_file):
|
||||
with open(tools_file, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
else:
|
||||
# If the JSON file does not exist, use the default definitions.
|
||||
return []
|
||||
except Exception as e:
|
||||
print(f"Warning: Unable to load tool definition JSON file: {str(e)}")
|
||||
return []
|
||||
|
||||
|
||||
def create_error_response(request_id: Any, code: int, message: str) -> Dict[str, Any]:
|
||||
"""Create a standardized error response."""
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def create_success_response(request_id: Any, result: Any) -> Dict[str, Any]:
|
||||
"""Create a standardized success response."""
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": result
|
||||
}
|
||||
|
||||
|
||||
def create_initialize_response(request_id: Any, server_name: str, server_version: str = "1.0.0") -> Dict[str, Any]:
|
||||
"""Create a standardized initialize response."""
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {
|
||||
"tools": {}
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": server_name,
|
||||
"version": server_version
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def create_ping_response(request_id: Any) -> Dict[str, Any]:
|
||||
"""Create a standardized ping response."""
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"pong": True
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def create_tools_list_response(request_id: Any, tools: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Create a standardized tools/list response."""
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"tools": tools
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def is_regex_pattern(pattern: str) -> bool:
|
||||
"""Check whether a string should be treated as a regular expression pattern."""
|
||||
# Check the /pattern/ format.
|
||||
if pattern.startswith('/') and pattern.endswith('/') and len(pattern) > 2:
|
||||
return True
|
||||
|
||||
# Check the r"pattern" or r'pattern' format.
|
||||
if pattern.startswith(('r"', "r'")) and pattern.endswith(('"', "'")) and len(pattern) > 3:
|
||||
return True
|
||||
|
||||
# Check whether it contains regex metacharacters.
|
||||
regex_chars = {'*', '+', '?', '|', '(', ')', '[', ']', '{', '}', '^', '$', '\\', '.'}
|
||||
return any(char in pattern for char in regex_chars)
|
||||
|
||||
|
||||
def compile_pattern(pattern: str) -> Union[re.Pattern, str, None]:
|
||||
"""Compile a regex pattern, or return the original string if it is not regex."""
|
||||
if not is_regex_pattern(pattern):
|
||||
return pattern
|
||||
|
||||
try:
|
||||
# Handle the /pattern/ format.
|
||||
if pattern.startswith('/') and pattern.endswith('/'):
|
||||
regex_body = pattern[1:-1]
|
||||
return re.compile(regex_body)
|
||||
|
||||
# Handle the r"pattern" or r'pattern' format.
|
||||
if pattern.startswith(('r"', "r'")) and pattern.endswith(('"', "'")):
|
||||
regex_body = pattern[2:-1]
|
||||
return re.compile(regex_body)
|
||||
|
||||
# Directly compile strings that contain regex metacharacters.
|
||||
return re.compile(pattern)
|
||||
except re.error as e:
|
||||
# If compilation fails, return None to indicate an invalid regex.
|
||||
print(f"Warning: Regular expression '{pattern}' compilation failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def handle_mcp_streaming(request_handler):
|
||||
"""Handle the standard main loop for MCP requests."""
|
||||
try:
|
||||
while True:
|
||||
# Read from stdin
|
||||
line = await asyncio.get_event_loop().run_in_executor(None, sys.stdin.readline)
|
||||
if not line:
|
||||
break
|
||||
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
request = json.loads(line)
|
||||
response = await request_handler(request)
|
||||
|
||||
# Write to stdout
|
||||
sys.stdout.write(json.dumps(response, ensure_ascii=False) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
except json.JSONDecodeError:
|
||||
error_response = {
|
||||
"jsonrpc": "2.0",
|
||||
"error": {
|
||||
"code": -32700,
|
||||
"message": "Parse error"
|
||||
}
|
||||
}
|
||||
sys.stdout.write(json.dumps(error_response, ensure_ascii=False) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
except Exception as e:
|
||||
error_response = {
|
||||
"jsonrpc": "2.0",
|
||||
"error": {
|
||||
"code": -32603,
|
||||
"message": f"Internal error: {str(e)}"
|
||||
}
|
||||
}
|
||||
sys.stdout.write(json.dumps(error_response, ensure_ascii=False) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
@ -1,6 +1,7 @@
|
||||
---
|
||||
name: managing-scripts
|
||||
description: Manages shared scripts repository for reusable data analysis tools. Check scripts/README.md before writing, design generalized scripts with parameters, and keep documentation in sync.
|
||||
category: Data & Retrieval
|
||||
---
|
||||
|
||||
# Managing Scripts
|
||||
|
||||
180
skills/developing/nfc-medicine-lookup/SKILL.md
Normal file
180
skills/developing/nfc-medicine-lookup/SKILL.md
Normal file
@ -0,0 +1,180 @@
|
||||
---
|
||||
name: nfc-medicine-lookup
|
||||
description: 药品检索技能,通过NFC芯片ID或药品名称查询药品信息。当用户提交NFC芯片ID、扫描药品标签、提到药品名称想了解用法、或提到"NFC"+"药"相关词汇时使用此技能。以语音助手身份向老人介绍药名、用途和用法用量。
|
||||
category: Developer Tools
|
||||
---
|
||||
|
||||
# NFC 药品检索
|
||||
|
||||
## Skill Structure
|
||||
|
||||
```
|
||||
nfc-medicine-lookup/
|
||||
├── SKILL.md # Core instruction file (this file)
|
||||
├── skill.yaml # Skill metadata
|
||||
├── scripts/
|
||||
│ └── nfc_medicine_lookup.py # Main lookup script
|
||||
```
|
||||
|
||||
## Overview
|
||||
|
||||
通过 **NFC芯片ID** 或 **药品名称** 查询对应的药品信息。本技能面向老年用户,以**语音助手**的身份,用简洁、亲切、易懂的语言告知:
|
||||
|
||||
1. 药品名称
|
||||
2. 这个药是干什么的
|
||||
3. 具体用法用量
|
||||
4. 注意事项
|
||||
|
||||
## 查询方式
|
||||
|
||||
支持两种查询入口,**至少提供一种**即可:
|
||||
|
||||
| Parameter | Description | Type | Required |
|
||||
|-----------|-------------|------|----------|
|
||||
| **nfc_id** | NFC芯片ID(如 100000) | string | 二选一 |
|
||||
| **name** | 药品名称,支持模糊匹配(如"阿莫西林") | string | 二选一 |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 方式一:通过NFC ID查询
|
||||
scripts/nfc_medicine_lookup.py --nfc-id "100000"
|
||||
|
||||
# 方式二:通过药品名称查询
|
||||
scripts/nfc_medicine_lookup.py --name "阿莫西林"
|
||||
```
|
||||
|
||||
## 查询流程
|
||||
|
||||
请严格按照以下流程执行:
|
||||
|
||||
### Step 1: 本地数据库查询
|
||||
|
||||
运行脚本查询本地药品库:
|
||||
|
||||
```bash
|
||||
# 有NFC ID时
|
||||
scripts/nfc_medicine_lookup.py --nfc-id "{nfc_id}"
|
||||
|
||||
# 有药品名称时
|
||||
scripts/nfc_medicine_lookup.py --name "{药品名称}"
|
||||
```
|
||||
|
||||
### Step 2: 判断查询结果
|
||||
|
||||
- **查到了** → 跳到 Step 4(语音回复)
|
||||
- **输出包含 `NOT_FOUND`** → 进入 Step 3(网络搜索兜底)
|
||||
|
||||
### Step 3: 网络搜索兜底(本地未命中时)
|
||||
|
||||
当本地药品库中查不到时,使用 WebSearch 工具搜索该药品的信息:
|
||||
|
||||
```
|
||||
WebSearch: "{药品名称} 用法用量 注意事项 说明书"
|
||||
```
|
||||
|
||||
从搜索结果中提取以下信息:
|
||||
- 药品全称
|
||||
- 药品类别
|
||||
- 主要功效/适应症
|
||||
- 用法用量
|
||||
- 关键注意事项
|
||||
|
||||
**重要**:网络搜索到的信息,回复时必须在末尾加上免责提醒:
|
||||
> 以上信息来自网络搜索,仅供参考。具体用药请遵医嘱,或咨询药师确认。
|
||||
|
||||
### Step 4: 以语音助手身份回复
|
||||
|
||||
无论信息来自本地库还是网络搜索,都按照下方「语音助手回复规范」进行回复。
|
||||
|
||||
## 语音助手回复规范
|
||||
|
||||
查询到药品后,你需要以**关怀老人的语音助手**身份回复。请遵循以下规范:
|
||||
|
||||
### 回复模板
|
||||
|
||||
```
|
||||
您好,这个药叫**{药品名称}**,属于{药品类别}。
|
||||
|
||||
**它的作用是**:{简洁描述药品用途}
|
||||
|
||||
**怎么吃**:{用法用量,用口语化表达}
|
||||
|
||||
**要注意**:{关键注意事项}
|
||||
|
||||
如果有任何不舒服,一定要及时告诉家人或去看医生哦。
|
||||
```
|
||||
|
||||
### 语言风格要求
|
||||
|
||||
- 使用口语化、亲切的表达,像家人在旁边叮嘱一样
|
||||
- 避免专业术语,用老人能听懂的话
|
||||
- 关键信息(药名、用量)要**重点强调**
|
||||
- 结尾加上一句关心的话
|
||||
- 整段回复控制在150字以内,适合语音播报
|
||||
|
||||
### 示例回复
|
||||
|
||||
**示例1:NFC ID查询命中**
|
||||
|
||||
输入: NFC ID = 100000
|
||||
|
||||
> 您好,这个药叫**阿莫西林胶囊**,是一种消炎药。
|
||||
>
|
||||
> **它的作用是**:用来治疗细菌引起的感染,比如嗓子发炎、咳嗽有痰这些情况。
|
||||
>
|
||||
> **怎么吃**:每次吃1粒,每天吃3到4次,每次间隔6到8个小时。记得在饭后吃,用温水把整粒药吞下去,不要嚼碎。
|
||||
>
|
||||
> **要注意**:如果您对青霉素过敏,这个药就不能吃。另外,医生让吃几天就吃几天,不要觉得好了就自己停药。
|
||||
>
|
||||
> 如果吃药后有任何不舒服,一定要及时告诉家人或去看医生哦。
|
||||
|
||||
**示例2:药品名称查询命中**
|
||||
|
||||
输入: name = "布洛芬"
|
||||
|
||||
> 您好,这个药叫**布洛芬缓释胶囊**,是一种止痛退烧药。
|
||||
>
|
||||
> **它的作用是**:用来缓解头痛、牙痛、关节痛,感冒发烧也可以吃。
|
||||
>
|
||||
> **怎么吃**:每次吃1粒,早晚各一次,饭后用温水整粒吞下去。
|
||||
>
|
||||
> **要注意**:有胃病的人要小心,连续吃不要超过5天,要是还疼就去看医生。
|
||||
>
|
||||
> 如果吃药后有任何不舒服,一定要及时告诉家人或去看医生哦。
|
||||
|
||||
**示例3:本地未命中,网络搜索兜底**
|
||||
|
||||
输入: name = "氯雷他定"(本地库没有)
|
||||
|
||||
> 您好,这个药叫**氯雷他定片**,是一种抗过敏药。
|
||||
>
|
||||
> **它的作用是**:用来缓解过敏引起的打喷嚏、流鼻涕、皮肤发痒这些症状。
|
||||
>
|
||||
> **怎么吃**:每次吃1片,每天吃1次就行,饭前饭后都可以。
|
||||
>
|
||||
> **要注意**:吃了这个药可能会有点犯困,吃药后尽量别开车。
|
||||
>
|
||||
> 以上信息来自网络搜索,仅供参考。具体用药请遵医嘱,或咨询药师确认。
|
||||
>
|
||||
> 如果有任何不舒服,一定要及时告诉家人或去看医生哦。
|
||||
|
||||
## NFC ID 药品对照表
|
||||
|
||||
| NFC ID | 药品名称 | 类别 |
|
||||
|--------|---------|------|
|
||||
| 100000 | 阿莫西林胶囊 | 抗生素 |
|
||||
| 100001 | 硝苯地平控释片 | 降压药 |
|
||||
| 100002 | 二甲双胍片 | 降糖药 |
|
||||
| 100003 | 阿司匹林肠溶片 | 抗血小板药 |
|
||||
| 100004 | 辛伐他汀片 | 降脂药 |
|
||||
| 100005 | 氨氯地平片 | 降压药 |
|
||||
| 100006 | 美托洛尔缓释片 | 降压药/心率控制 |
|
||||
| 100007 | 奥美拉唑肠溶胶囊 | 胃药 |
|
||||
| 100008 | 氯吡格雷片 | 抗血小板药 |
|
||||
| 100009 | 螺内酯片 | 利尿药 |
|
||||
| 100010 | 复方丹参滴丸 | 心血管中成药 |
|
||||
| 100011 | 蒙脱石散 | 止泻药 |
|
||||
| 100012 | 布洛芬缓释胶囊 | 解热镇痛药 |
|
||||
| 100013 | 碳酸钙D3片 | 补钙药 |
|
||||
| 100014 | 甲钴胺片 | 营养神经药 |
|
||||
@ -8,5 +8,6 @@
|
||||
"command": "python hooks/pre_prompt.py"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"category": "Developer Tools"
|
||||
}
|
||||
|
||||
@ -17,5 +17,6 @@
|
||||
"./pmda_server.py"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"category": "Developer Tools"
|
||||
}
|
||||
|
||||
57
skills/developing/ppt-outline/SKILL.md
Normal file
57
skills/developing/ppt-outline/SKILL.md
Normal file
@ -0,0 +1,57 @@
|
||||
---
|
||||
name: ppt-outline
|
||||
description: "PPT outline and HTML presentation generator. PPT大纲、PPT模板、演示文稿、presentation、PowerPoint、幻灯片、slides、HTML演示文稿、HTML slides、浏览器演示、商业路演、pitch deck、BP商业计划书、business plan、工作汇报PPT、培训课件、课件大纲、产品介绍PPT、产品发布、keynote、演讲稿、述职PPT、答辩PPT、竞品分析PPT、毕业答辩、论文答辩、项目复盘、迭代复盘。Generate PPT outlines and standalone HTML presentations (open directly in browser, no dependencies). Use when: (1) creating PPT/presentation outlines, (2) building pitch deck/BP structures, (3) preparing work report slides, (4) designing training course outlines, (5) creating thesis defense PPT outlines, (6) building project review/retrospective PPTs, (7) generating HTML slide decks for browser-based presentations, (8) any PowerPoint/Keynote/Google Slides planning. 适用场景:做PPT大纲、写路演BP、汇报PPT结构、培训课件大纲、毕业答辩PPT、项目复盘PPT、述职答辩PPT、生成HTML演示文稿(浏览器直接打开,支持dark/light/tech/minimal四种风格)。"
|
||||
category: Document Processing
|
||||
---
|
||||
|
||||
# ppt-outline
|
||||
|
||||
PPT大纲和演示文稿结构生成器。商业路演、工作汇报、产品介绍、培训课件。
|
||||
|
||||
## 为什么用这个 Skill? / Why This Skill?
|
||||
|
||||
- **场景化大纲**:路演BP有固定结构(痛点→方案→市场→团队→融资),汇报有汇报的逻辑,不是万能模板
|
||||
- **每页要点**:不只给标题,每页都有2-4个要点提示,拿来直接填内容
|
||||
- **页数控制**:`--slides 10` 控制总页数,按需伸缩
|
||||
- Compared to asking AI directly: scenario-specific slide structures (pitch vs report vs training), per-slide talking points, and slide count control
|
||||
|
||||
## Usage
|
||||
|
||||
Run the script at `scripts/ppt.sh`:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `scripts/ppt.sh outline "主题" [--slides 10]` | 生成PPT大纲(每页标题+要点) |
|
||||
| `scripts/ppt.sh pitch "项目名"` | 商业路演BP大纲 |
|
||||
| `scripts/ppt.sh report "汇报主题"` | 工作汇报PPT大纲 |
|
||||
| `scripts/ppt.sh training "课程主题"` | 培训课件大纲 |
|
||||
| `scripts/ppt.sh defense "论文题目"` | 毕业答辩PPT大纲 |
|
||||
| `scripts/ppt.sh review "项目名"` | 项目复盘PPT大纲 |
|
||||
| `scripts/ppt.sh html "主题" [--style S]` | 生成HTML演示文稿(浏览器直接打开) |
|
||||
| `scripts/ppt.sh help` | 显示帮助信息 |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# 通用PPT大纲(指定页数)
|
||||
bash scripts/ppt.sh outline "人工智能在医疗领域的应用" --slides 12
|
||||
|
||||
# 商业路演
|
||||
bash scripts/ppt.sh pitch "智能客服SaaS平台"
|
||||
|
||||
# 工作汇报
|
||||
bash scripts/ppt.sh report "2024年Q4部门工作总结"
|
||||
|
||||
# 培训课件
|
||||
bash scripts/ppt.sh training "新员工入职培训-公司文化"
|
||||
|
||||
# 毕业答辩
|
||||
bash scripts/ppt.sh defense "社交媒体对消费行为的影响研究"
|
||||
|
||||
# 项目复盘
|
||||
bash scripts/ppt.sh review "双十一大促活动"
|
||||
|
||||
# 生成HTML演示文稿(浏览器直接打开)
|
||||
bash scripts/ppt.sh html "AI在医疗的应用" --style tech
|
||||
# 支持风格:dark(默认深色科技) | light(白色商务) | tech(渐变科技) | minimal(极简)
|
||||
```
|
||||
@ -1,6 +1,7 @@
|
||||
---
|
||||
name: rag-retrieve
|
||||
description: RAG retrieval skill for querying and retrieving relevant documents from knowledge base. Use this skill when users need to search documentation, retrieve knowledge base articles, or get context from a vector database. Supports semantic search with configurable top-k results.
|
||||
category: Data & Retrieval
|
||||
---
|
||||
|
||||
# RAG Retrieve
|
||||
|
||||
@ -18,5 +18,6 @@
|
||||
"{bot_id}"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"category": "Data & Retrieval"
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
---
|
||||
name: static-hosting
|
||||
description: Serve static HTML/CSS/JS/images from robot project directories via the built-in FastAPI static file server. Use when generating web pages, reports, or interactive content for a bot.
|
||||
category: Web Services
|
||||
---
|
||||
|
||||
# Static Hosting
|
||||
|
||||
@ -30,8 +30,11 @@
|
||||
"mcpServers": {
|
||||
"user-context-example": {
|
||||
"command": "echo",
|
||||
"args": ["Example MCP server for user context loader"],
|
||||
"args": [
|
||||
"Example MCP server for user context loader"
|
||||
],
|
||||
"comment": "这是一个示例 MCP 配置,实际使用时替换为真实的 MCP 服务器"
|
||||
}
|
||||
}
|
||||
},
|
||||
"category": "Developer Tools"
|
||||
}
|
||||
|
||||
91
skills/developing/z-card-image/SKILL.md
Normal file
91
skills/developing/z-card-image/SKILL.md
Normal file
@ -0,0 +1,91 @@
|
||||
---
|
||||
name: z-card-image
|
||||
version: 1.1.0
|
||||
description: 生成配图、封面图、卡片图、文字海报、公众号文章封面图、微信公众号头图、X 风格帖子分享图、帖子长图、社媒帖子长图。适用于帖子类型数据、post data、social posts、tweet/thread、转发推文、转发帖子、小绿书配图、图片封面、card image。
|
||||
metadata:
|
||||
openclaw:
|
||||
requires:
|
||||
bins:
|
||||
- python3
|
||||
- google-chrome
|
||||
category: Creative Generation
|
||||
---
|
||||
|
||||
# z-card-image
|
||||
|
||||
将用户提供的文案渲染成 PNG 卡片图。
|
||||
支持短文案封面图、长文分页图、X 风格帖子分享长图,以及公众号文章封面图。只要输入是“帖子类型数据”并希望导出成 X 风格长图,都应走 `x-like-posts`。
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Python 3
|
||||
- Google Chrome(macOS:`/Applications/Google Chrome.app`;Linux:`chromium` 需修改脚本路径)
|
||||
|
||||
## 执行流程
|
||||
|
||||
0. **环境提示**(用户触发时检测一次,有问题给提示,不中止流程):
|
||||
- `python3 --version` → 失败则告知:「⚠️ 未检测到 Python 3,渲染可能失败」
|
||||
- 检查 Chrome 路径 → 失败则提示安装
|
||||
|
||||
1. **识别场景**:
|
||||
- 短文案封面图 → `poster-3-4`
|
||||
- 长文分页图 → `article-3-4`
|
||||
- X 风格帖子分享图 / 帖子长图 / 帖子类型数据 → `x-like-posts`
|
||||
- 公众号文章封面图 → `wechat-cover-split`
|
||||
2. **查模板规则**:根据模板在「模板索引」中找到对应规范文档,读取后按其规则处理文案和参数。**如用户要求高亮:整行用 `--hl1/hl2/hl3`,按词用 `--highlight-words`(逗号分隔),两者可同时使用,不能忽略**
|
||||
3. **确认署名**:先询问用户底部署名文字(`--footer`),如:「请告诉我你的署名,例如"公众号 · 你的名字"」。用户未回答或要求跳过时,使用脚本默认值。
|
||||
4. **确定配色**:拿到署名后,再确定配色方案:
|
||||
- 用户提到"小红书配图" → 推荐方案 B(热情红)
|
||||
- 用户提到"小绿书"或"公众号配图" → 推荐方案 A(清新绿)
|
||||
- 用户提到"推特长图"/"X 风格" → 使用 `render_x_like_posts.py` 自带默认值(Twitter 蓝白灰)
|
||||
- 其他场景 → 询问用户选择下方配色方案:
|
||||
|
||||
| 方案 | 风格 | `--bg` | `--highlight` | 适用场景 |
|
||||
|------|------|--------|--------------|---------|
|
||||
| A. 清新绿 | 公众号/小绿书 | `#e6f5ef` | `#22a854` | 公众号配图、小绿书 |
|
||||
| B. 热情红 | 小红书 | `#fdecea` | `#e53935` | 小红书配图 |
|
||||
| C. 科技蓝 | 知乎/技术 | `#e8f4fd` | `#1976d2` | 技术文章、知乎 |
|
||||
| D. 暖橙黄 | 活力/营销 | `#fff8e1` | `#f57c00` | 活动海报、营销 |
|
||||
| E. 优雅紫 | 时尚/文艺 | `#f3e5f5` | `#7b1fa2` | 文艺、时尚类 |
|
||||
| F. 经典黑白 | 极简 | `#f5f5f5` | `#212121` | 极简风格 |
|
||||
|
||||
用户也可自定义 `--bg` 和 `--highlight`。用户未回答或要求跳过时,使用脚本默认值,不做额外覆盖。
|
||||
5. **渲染输出**:
|
||||
- `poster-3-4` → 执行 `render_card.py`
|
||||
- `article-3-4` → 执行 `render_article.py`
|
||||
- `x-like-posts` → 执行 `render_x_like_posts.py`
|
||||
- `wechat-cover-split` → 执行 `render_card.py`
|
||||
- 默认 `--out` 填 `tmp/...png`;如用户指定导出位置,可直接传绝对路径或相对路径
|
||||
6. **输出产物**:生成 PNG 到指定路径,供后续发送、裁切或复用;如需给外部工具上传,仍应避免写入系统 `/tmp/`
|
||||
|
||||
## x-like-posts 导航
|
||||
|
||||
`x-like-posts` 用于“帖子类型数据 → X 风格分享长图”。
|
||||
|
||||
当命中这条路线时,继续读取:
|
||||
|
||||
- [references/x-like-posts.md](references/x-like-posts.md):输入 JSON 格式、可显示字段、时间规则、导出规则
|
||||
- [references/tweet-thread.md](references/tweet-thread.md):旧命名兼容说明
|
||||
|
||||
## 输入校验
|
||||
|
||||
- **比例不存在**:驳回请求,告知当前支持的比例列表,询问是否新增模板
|
||||
- **文案超出模板字数上限**:先自动拆分/缩写后再渲染,不要直接塞入
|
||||
- **帖子过多**:按规范拆成多张 `Part 1 / Part 2`,不要把超长内容强行塞进一张
|
||||
- **公众号封面标题过长**:先压缩成 2~3 行短标题,再渲染,不能把完整长标题硬塞进模板
|
||||
|
||||
## 模板索引
|
||||
|
||||
| 模板名 | 比例 | 尺寸 | 用途 | 规范文档 |
|
||||
|--------|------|------|------|---------|
|
||||
| `poster-3-4` | 3:4 | 900×1200 | 文字海报(金句/大字报/封面) | [references/poster-3-4.md](references/poster-3-4.md) |
|
||||
| `article-3-4` | 3:4 | 900×1200 | 长文分页卡片 | [references/article-3-4.md](references/article-3-4.md) |
|
||||
| `x-like-posts` | 自适应长图 | 900px 宽 | X 风格帖子分享长图 | [references/x-like-posts.md](references/x-like-posts.md) |
|
||||
| `wechat-cover-split` | 335:100 | 1340×400 | 公众号文章封面长条图(左标题右 icon) | [references/wechat-cover-split.md](references/wechat-cover-split.md) |
|
||||
|
||||
## 新增模板
|
||||
|
||||
1. 新建 `assets/templates/<name>.html`
|
||||
2. 在 `render_card.py` 的 `size_map` 里注册尺寸
|
||||
3. 在上方模板索引中添加一行
|
||||
4. 创建对应 `references/<name>.md`,记录该模板的参数、字数上限、配图选取规则
|
||||
@ -1,6 +1,7 @@
|
||||
---
|
||||
name: kfs-answer
|
||||
description: Primary skill for answering ALL questions about the datasets knowledge base. Search files, run queries (SQL / markdown), and return answers with citations. MUST be used first for any data-related question.
|
||||
category: Data & Retrieval
|
||||
---
|
||||
|
||||
# kfs-answer
|
||||
|
||||
@ -18,5 +18,6 @@
|
||||
"{bot_id}"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"category": "Data & Retrieval"
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
---
|
||||
name: board-meeting-pack-helper
|
||||
description: Assemble board-meeting materials into a coherent pack with agenda logic, board-level KPIs, strategic risks, governance context, and decision-ready content. Use this whenever users ask for board materials, board pack, board meeting agenda, governance updates, director pre-read, 取締役会資料, or resolution-ready content for executive or board review; use it for board-level governance materials, not for generic executive one-pagers.
|
||||
category: Writing & Reporting
|
||||
---
|
||||
|
||||
# Board Meeting Pack Helper
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
---
|
||||
name: customer-reply-tone
|
||||
description: Rewrite customer-facing replies in the right tone while preserving factual accuracy, accountability, and clear next steps across sensitive support, delivery, and account situations. Use this whenever users ask to soften, professionalize, de-escalate, polish, or reframe a customer email or chat response, including complaint reply, support response polish, or クレーム返信; use it for reply rewriting and de-escalation, not for sales follow-up or general Japanese business writing.
|
||||
category: Writing & Reporting
|
||||
---
|
||||
|
||||
# Customer Reply Tone
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
---
|
||||
name: exec-brief-1pager
|
||||
description: Turn complex business, product, and operational topics into a one-page executive brief with decision-ready insights, options, and recommended actions. Use this whenever users ask for an executive summary, leadership brief, one-pager, decision memo, CEO brief, or key points at a glance for senior leadership; use it for one-page decision support, not for recurring status updates or board meeting packs.
|
||||
category: Writing & Reporting
|
||||
---
|
||||
|
||||
# Exec Brief 1Pager
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
---
|
||||
name: incident-postmortem-ja
|
||||
description: Create structured postmortems and 障害報告書 for incidents, outages, and service failures with clear timelines, root-cause analysis, and preventive actions. Use this whenever users ask for an incident report, postmortem, RCA, incident review, 障害報告, 障害報告書, 振り返り, or 再発防止計画 focused on system and process improvement; use it for formal incident analysis, not for routine status updates or personal blame.
|
||||
category: Writing & Reporting
|
||||
---
|
||||
|
||||
# Incident Postmortem JA
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
---
|
||||
name: japan-compliance-checker
|
||||
description: Review Japan-specific compliance risks in business text, campaign copy, contracts, and operating processes with clear, practical screening guidance. Use this whenever users ask for Japan compliance review, legal review, regulatory check, 法務チェック, コンプラ確認, 契約レビュー, or 広告審査 within the v1 scope of APPI, 景品表示法, and 下請法; use it for risk screening rather than drafting, anonymization, or legal advice.
|
||||
category: Compliance & Security
|
||||
---
|
||||
|
||||
# Japan Compliance Checker
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
---
|
||||
name: japanese-business-writer
|
||||
description: Draft and polish formal Japanese business writing for emails, notices, request letters, cover notes, and workplace communication with clear structure and appropriate 敬語. Use this whenever users ask for Japanese business writing, formal JP writing, 敬語 polishing, 文面添削, 依頼メール, 案内文, 送付状, or 社内通知; use it for writing quality and business tone, not for compliance review or complaint de-escalation.
|
||||
category: Writing & Reporting
|
||||
---
|
||||
|
||||
# Japanese Business Writer
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
---
|
||||
name: japanese-pii-redactor
|
||||
description: Redact, anonymize, and de-identify personal information in Japanese-language or mixed-language text and tabular data while preserving analytical usefulness. Use this whenever users ask for PII redaction, PII scrub, de-identification, 個人情報匿名化, 匿名加工, 仮名化, 秘匿化, or マスキング; use it for executing anonymization rules, not for legal interpretation or general writing polish.
|
||||
category: Compliance & Security
|
||||
---
|
||||
|
||||
# Japanese PII Redactor
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
---
|
||||
name: kfs-answer
|
||||
description: Primary skill for answering ALL questions about the datasets knowledge base. Search files, run queries (SQL / markdown), and return answers with citations. MUST be used first for any data-related question.
|
||||
category: Data & Retrieval
|
||||
---
|
||||
|
||||
# kfs-answer
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
---
|
||||
name: meeting-minutes-action
|
||||
description: Turn raw meeting notes, transcripts, and discussion logs into structured meeting minutes, decision summaries, and follow-up action items with owners and deadlines. Use this whenever users ask for meeting minutes, meeting summary, 議事録, 議事メモ整理, 打合せ記録, 決定事項整理, or follow-up tasks from a single meeting; use it for minutes and action tracking, not for periodic project or leadership status reporting.
|
||||
category: Writing & Reporting
|
||||
---
|
||||
|
||||
# Meeting Minutes Action
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
---
|
||||
name: proposal-rfp-writer
|
||||
description: Create structured, client-ready proposal and RFP response drafts that map requirements to solutions, differentiate clearly, and stay easy for evaluators to review. Use this whenever users ask to respond to an RFP, RFQ, bid request, tender, vendor questionnaire, procurement questionnaire, 提案書, bid response, or tender response; use it for evaluator-facing requirement mapping, not for quotes, SOWs, or delivery acceptance terms.
|
||||
category: Writing & Reporting
|
||||
---
|
||||
|
||||
# Proposal RFP Writer
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
---
|
||||
name: quotation-sow-drafter
|
||||
description: Draft coherent quotations and statements of work that align scope, deliverables, milestones, assumptions, and commercial terms without blurring commitments and estimates. Use this whenever users ask for a quote, quote draft, pricing sheet, SOW, scope document, work order, implementation plan draft, 見積書, or 作業範囲定義; use it for pricing, scope, milestones, and acceptance terms, not for RFP questionnaires or evaluator-facing bid responses.
|
||||
category: Writing & Reporting
|
||||
---
|
||||
|
||||
# Quotation SOW Drafter
|
||||
|
||||
@ -18,5 +18,6 @@
|
||||
"{bot_id}"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"category": "Data & Retrieval"
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
---
|
||||
name: sales-followup
|
||||
description: Draft sales follow-up messages that move deals forward with the right mix of urgency, clarity, and low-friction next steps after calls, demos, proposals, and stalled threads. Use this whenever users ask to follow up, re-engage, nudge, check in, request a decision, client follow-up, 商談フォロー, 提案後フォロー, or 検討状況確認 after a demo, quote, meeting, or proposal; use it for sales progression, not for complaint handling or tone-only rewrites.
|
||||
category: Writing & Reporting
|
||||
---
|
||||
|
||||
# Sales Follow-Up
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
---
|
||||
name: stakeholder-update
|
||||
description: Produce concise stakeholder updates that summarize status, progress, risks, decisions, and next actions in a format leaders and cross-functional teams can scan quickly. Use this whenever users ask for a status update, progress update, weekly update, monthly update, leadership summary, project brief, 経営報告, or エスカレーション共有; use it for recurring project or business reporting, not for meeting minutes or one-page decision memos.
|
||||
category: Writing & Reporting
|
||||
---
|
||||
|
||||
# Stakeholder Update
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
---
|
||||
name: static-hosting
|
||||
description: Serve static HTML/CSS/JS/images from robot project directories via the built-in FastAPI static file server. Use when generating web pages, reports, or interactive content for a bot.
|
||||
category: Web Services
|
||||
---
|
||||
|
||||
# Static Hosting
|
||||
|
||||
Loading…
Reference in New Issue
Block a user