Merge branch 'feature/mcp-ui' into developing
This commit is contained in:
commit
22b9ad4877
@ -1,23 +1,37 @@
|
||||
# Skill 功能
|
||||
|
||||
> 负责范围:技能包管理服务 - 核心实现
|
||||
<<<<<<< Updated upstream
|
||||
> 最后更新:2026-04-20
|
||||
=======
|
||||
|
||||
> 最后更新:2026-05-20
|
||||
>>>>>>> Stashed changes
|
||||
|
||||
## 当前状态
|
||||
|
||||
Skill 系统支持两种来源:官方 skills (`./skills/`) 和用户 skills (`projects/uploads/{bot_id}/skills/`)。支持 Hook 系统和 MCP 服务器配置,通过 SKILL.md 或 plugin.json 定义元数据。
|
||||
|
||||
<<<<<<< Updated upstream
|
||||
=======
|
||||
MCP UI 类 skill 已按 MCP Apps 模式改造:工具返回数据,静态 HTML App 由 host 加载后通过 postMessage 接收数据渲染。
|
||||
|
||||
目前已新增一批**纯 `SKILL.md` 型业务 skill MVP**,用于研究、摘要、报告和情报编排,底层文件处理与外部检索能力继续复用既有 skill。
|
||||
|
||||
>>>>>>> Stashed changes
|
||||
## 核心文件
|
||||
|
||||
- `routes/skill_manager.py` - Skill 上传/删除/列表 API
|
||||
- `agent/plugin_hook_loader.py` - Hook 系统实现
|
||||
- `agent/deep_assistant.py` - `CustomSkillsMiddleware`
|
||||
- `agent/prompt_loader.py` - PrePrompt hooks + MCP 配置合并
|
||||
- `routes/mcp_resources.py` - MCP App 静态 HTML resource REST 入口
|
||||
- `skills/` - 官方 skills 目录
|
||||
- `skills_developing/` - 开发中 skills
|
||||
|
||||
## 最近重要事项
|
||||
|
||||
- 2026-05-20: `mcp-ui` 和 `data-dashboard` 改为 MCP Apps 标准模式,App HTML 放在 skill 的 `apps/` 目录,由 host 加载后 postMessage 数据
|
||||
- 2026-04-20: 为 `rag-retrieve` 新增 `retrieval-policy-forbidden-self-knowledge.md`,禁止知识问答场景使用模型自身知识补全答案,要求严格基于检索证据作答
|
||||
- 2026-04-19: 环境变量 `SKILLS_SUBDIR` 重命名为 `PROJECT_NAME`,用于选择 `skills/{PROJECT_NAME}` 和 `skills/autoload/{PROJECT_NAME}` 目录
|
||||
- 2026-04-19: `create_robot_project` 的 autoload 去重和 stale 清理补强,autoload 目录也纳入 managed 清理,避免 `rag-retrieve-only` 场景下旧的 `rag-retrieve` 残留
|
||||
@ -27,6 +41,18 @@ Skill 系统支持两种来源:官方 skills (`./skills/`) 和用户 skills (`
|
||||
|
||||
## Gotchas(开发必读)
|
||||
|
||||
<<<<<<< Updated upstream
|
||||
=======
|
||||
- ⚠️ MCP App resource REST 读取路径是 `projects/robot/{bot_id}/skills/{server_name}/apps/{resource_name}.html`,前端 bot_id 应由 `ChatView` 从当前 bot 传给 `ChatMessage`,不要在子组件里重新调用 `useBotManager()`
|
||||
- ⚠️ `langchain-mcp-adapters` 会丢失 `EmbeddedResource` 的 uri/_meta;MCP App payload 需作为 text JSON 传递给前端识别
|
||||
|
||||
- ⚠️ 纯 `SKILL.md` 型业务 skill 适合先承载 workflow、输入模板、输出模板;需要稳定文件产出或自动化时再补 `scripts/`
|
||||
- ⚠️ 新业务 skill 应复用既有基础能力 skill(如 `baidu-search`、`xlsx`、`docx`、`pdf`、`schedule-job`、`imap-smtp-email`),避免重复定义底层工具能力
|
||||
- ⚠️ 新增脚本优先采用 `Python + argparse + JSON stdout`,比 `argv[1] JSON` 更适合自动化链路
|
||||
- ⚠️ `auto-daily-summary` 需要特别注意中文分句、action 边界截断、risk 窗口裁剪,否则容易把整句/整段吞进去
|
||||
- ⚠️ `competitor-news-intel` 的 payload 校验应按命令拆分(collect/analyze/run),不要共用一套最小校验
|
||||
- ⚠️ `competitor-news-intel` 的 `collect/run` 依赖 `BAIDU_API_KEY`;无该环境变量时应返回稳定错误 JSON,不要静默降级
|
||||
>>>>>>> Stashed changes
|
||||
- ⚠️ `create_robot_project` 的 autoload 去重是“包含匹配”,只要传入的 skill 字符串里包含 autoload skill 名,就不会重复自动加载
|
||||
- ⚠️ `_extract_skills_to_robot` 只会从 `skills/{PROJECT_NAME}` 读取官方 skills,默认是 `common`
|
||||
- ⚠️ 执行脚本必须使用绝对路径
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
# 2026-Q2 Skill Changelog
|
||||
|
||||
### 2026-05-20
|
||||
- **变更**: `mcp-ui` 和 `data-dashboard` 从自定义 `uri + data` 工具协议改为 MCP Apps 模式
|
||||
- **说明**: 静态 HTML App 放在各 skill 的 `apps/` 目录,host 通过 resource URI 加载 iframe,再用 postMessage 传递工具数据
|
||||
- **修复**: 前端 MCP App resource 请求改为由 `ChatView` 向 `ChatMessage` 传入当前 botId,避免子组件重新创建 bot manager 导致 bot_id 为空
|
||||
- **作者**: Claude
|
||||
|
||||
### 2026-04-20
|
||||
- **新增**: `skills/autoload/onprem/rag-retrieve/hooks/retrieval-policy-forbidden-self-knowledge.md`
|
||||
- **说明**: 基于现有 `retrieval-policy.md` 衍生出更严格的检索策略,明确禁止在知识问答场景中使用模型自身知识补全答案,要求回答只能来自检索证据
|
||||
|
||||
@ -311,6 +311,22 @@ async def init_agent(config: AgentConfig):
|
||||
sandbox, sandbox_type, workspace_root = await sandbox_task
|
||||
logger.info(f"init_agent sandbox ready, elapsed: {time.time() - create_start:.3f}s")
|
||||
|
||||
# Inject shell_env into Daytona sandbox via BASH_ENV file
|
||||
if sandbox is not None and sandbox_type == "daytona":
|
||||
_shell_env = {
|
||||
"ASSISTANT_ID": config.bot_id,
|
||||
"USER_IDENTIFIER": config.user_identifier,
|
||||
"TRACE_ID": config.trace_id,
|
||||
"ENABLE_SELF_KNOWLEDGE": str(config.enable_self_knowledge).lower(),
|
||||
**(config.shell_env or {}),
|
||||
}
|
||||
env_lines = "\n".join(f'export {k}="{v}"' for k, v in _shell_env.items() if v is not None)
|
||||
if env_lines:
|
||||
from utils.daytona_sync import REMOTE_BASH_ENV_PATH, REMOTE_WORKSPACE_ROOT
|
||||
bash_env_content = f"cd {REMOTE_WORKSPACE_ROOT}\n{env_lines}"
|
||||
sandbox.execute(f"cat > {REMOTE_BASH_ENV_PATH} << 'ENVEOF'\n{bash_env_content}\nENVEOF")
|
||||
logger.info(f"Injected {len(_shell_env)} env vars into Daytona BASH_ENV")
|
||||
|
||||
# Load sub-agents from skill directories
|
||||
subagents = await load_subagents(
|
||||
bot_id=config.bot_id,
|
||||
|
||||
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
@ -80,7 +80,6 @@ from utils.log_util.logger import init_with_fastapi
|
||||
# Initialize logger
|
||||
logger = logging.getLogger('app')
|
||||
|
||||
# Import route modules
|
||||
from routes import chat, files, projects, system, skill_manager, database, memory
|
||||
from routes.webdav import wsgidav_app
|
||||
|
||||
@ -189,6 +188,36 @@ app.mount("/public", StaticFiles(directory="public"), name="static")
|
||||
# Mount robot projects directory as static files (supports HTML/CSS/JS/images)
|
||||
app.mount("/robots", StaticFiles(directory="projects/robot", html=True), name="robots")
|
||||
|
||||
|
||||
# Daytona fallback middleware: fetch file from sandbox when local file is missing
|
||||
from utils.settings import DAYTONA_ENABLED
|
||||
|
||||
if DAYTONA_ENABLED:
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from starlette.responses import FileResponse
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent
|
||||
|
||||
@app.middleware("http")
|
||||
async def daytona_robots_fallback(request, call_next):
|
||||
response = await call_next(request)
|
||||
if response.status_code == 404 and request.url.path.startswith("/robots/"):
|
||||
path_after = request.url.path.removeprefix("/robots/")
|
||||
parts = path_after.split("/", 1)
|
||||
if len(parts) == 2:
|
||||
bot_id, rel_path = parts
|
||||
local_path = PROJECT_ROOT / "projects" / "robot" / bot_id / rel_path
|
||||
if not local_path.is_file():
|
||||
from utils.daytona_file_fetcher import fetch_file_from_daytona
|
||||
fetched = await asyncio.to_thread(
|
||||
fetch_file_from_daytona, bot_id, rel_path, local_path
|
||||
)
|
||||
if fetched and local_path.is_file():
|
||||
return FileResponse(str(local_path))
|
||||
return response
|
||||
|
||||
|
||||
# Add CORS middleware for frontend pages
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@ -210,6 +239,9 @@ app.include_router(skill_manager.router)
|
||||
app.include_router(database.router)
|
||||
app.include_router(memory.router)
|
||||
|
||||
|
||||
# 挂载 WsgiDAV(WSGI 应用通过 WSGIMiddleware 集成到 ASGI)
|
||||
|
||||
# Register the file management API routes
|
||||
app.include_router(file_manager_router)
|
||||
|
||||
|
||||
@ -42,7 +42,6 @@ dependencies = [
|
||||
"daytona-sdk",
|
||||
"langchain-daytona",
|
||||
"langfuse (>=2.0.0,<4.0.0)",
|
||||
"mcp-ui-server (>=1.0.0,<2.0.0)",
|
||||
]
|
||||
|
||||
[tool.poetry.requires-plugins]
|
||||
|
||||
@ -114,8 +114,7 @@ async def enhanced_generate_stream_response(
|
||||
chunk_args = tool_call_chunk.get("args") if isinstance(tool_call_chunk, dict) else getattr(tool_call_chunk, "args", None)
|
||||
if chunk_name:
|
||||
current_tool_name = chunk_name
|
||||
# Always output ask_user and render_ui tool calls even when tool_response is disabled
|
||||
if config.tool_response or current_tool_name in ('ask_user', 'render_ui'):
|
||||
if config.tool_response:
|
||||
if chunk_name:
|
||||
new_content = f"[{message_tag}] {chunk_name}\n"
|
||||
if chunk_args:
|
||||
@ -144,11 +143,11 @@ async def enhanced_generate_stream_response(
|
||||
elif isinstance(msg, ToolMessage) and msg.content:
|
||||
message_tag = "TOOL_RESPONSE"
|
||||
waiting_for_answer_first_char = False
|
||||
# Always output UIResource responses even when tool_response is disabled
|
||||
# Always output MCP App responses even when tool_response is disabled
|
||||
is_ui_resource = (
|
||||
msg.text
|
||||
and msg.text.lstrip().startswith('{"')
|
||||
and '"ui://' in msg.text
|
||||
and '"type":"app"' in msg.text
|
||||
)
|
||||
if config.tool_response or is_ui_resource:
|
||||
new_content = f"[{message_tag}] {msg.name}\n{msg.text}\n"
|
||||
|
||||
@ -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": [
|
||||
{
|
||||
|
||||
148
skills/common/data-dashboard/apps/chart.html
Normal file
148
skills/common/data-dashboard/apps/chart.html
Normal file
@ -0,0 +1,148 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Chart</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 16px; }
|
||||
#chart { width: 100%; height: 100%; min-height: 360px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="chart"></div>
|
||||
<script>
|
||||
(function () {
|
||||
var chartInstance = null;
|
||||
var COLOR_PALETTE = ['#5470c6','#91cc75','#fac858','#ee6666','#73c0de','#3ba272','#fc8452','#9a60b4'];
|
||||
|
||||
function baseOption(title) {
|
||||
return {
|
||||
title: { text: title, left: 'center', top: 8, textStyle: { fontSize: 16, fontWeight: 600 } },
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { top: 36 },
|
||||
grid: { top: 80, left: 60, right: 30, bottom: 40 },
|
||||
color: COLOR_PALETTE
|
||||
};
|
||||
}
|
||||
|
||||
function labelCfg(show) { return { show: show, position: 'top', fontSize: 11 }; }
|
||||
|
||||
var builders = {
|
||||
line: function (d) {
|
||||
var opt = baseOption(d.title);
|
||||
opt.xAxis = { type: 'category', data: d.data.categories || [], boundaryGap: false };
|
||||
opt.yAxis = { type: 'value' };
|
||||
opt.series = (d.data.series || []).map(function (s) {
|
||||
return { name: s.name, type: 'line', data: s.data, smooth: !!d.smooth,
|
||||
stack: d.stacked ? 'total' : null, areaStyle: d.stacked ? {} : null,
|
||||
label: labelCfg(!!d.show_label) };
|
||||
});
|
||||
return opt;
|
||||
},
|
||||
bar: function (d) {
|
||||
var opt = baseOption(d.title);
|
||||
opt.xAxis = { type: 'category', data: d.data.categories || [] };
|
||||
opt.yAxis = { type: 'value' };
|
||||
opt.series = (d.data.series || []).map(function (s) {
|
||||
return { name: s.name, type: 'bar', data: s.data, stack: d.stacked ? 'total' : null,
|
||||
label: labelCfg(!!d.show_label), barMaxWidth: 50 };
|
||||
});
|
||||
return opt;
|
||||
},
|
||||
pie: function (d) {
|
||||
var opt = baseOption(d.title);
|
||||
delete opt.grid;
|
||||
opt.tooltip = { trigger: 'item', formatter: '{b}: {c} ({d}%)' };
|
||||
var s0 = (d.data.series || [])[0] || {};
|
||||
opt.series = [{
|
||||
name: s0.name || d.title, type: 'pie', radius: ['40%','70%'], center: ['50%','55%'],
|
||||
data: s0.data || [],
|
||||
emphasis: { itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0,0,0,0.5)' } },
|
||||
label: { show: true, formatter: '{b}: {d}%' },
|
||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 }
|
||||
}];
|
||||
return opt;
|
||||
},
|
||||
radar: function (d) {
|
||||
var opt = baseOption(d.title);
|
||||
delete opt.grid;
|
||||
opt.tooltip = { trigger: 'item' };
|
||||
var cats = d.data.categories || [];
|
||||
var series = d.data.series || [];
|
||||
var maxVals = cats.map(function () { return 0; });
|
||||
series.forEach(function (s) {
|
||||
s.data.forEach(function (v, i) { if (i < maxVals.length && v > maxVals[i]) maxVals[i] = v; });
|
||||
});
|
||||
opt.radar = {
|
||||
indicator: cats.map(function (c, i) { return { name: c, max: Math.round(maxVals[i] * 1.2) || 100 }; }),
|
||||
center: ['50%','55%']
|
||||
};
|
||||
opt.series = [{
|
||||
type: 'radar',
|
||||
data: series.map(function (s) { return { value: s.data, name: s.name }; }),
|
||||
areaStyle: { opacity: 0.15 }
|
||||
}];
|
||||
return opt;
|
||||
},
|
||||
scatter: function (d) {
|
||||
var opt = baseOption(d.title);
|
||||
opt.tooltip = { trigger: 'item', formatter: '{a}: ({c})' };
|
||||
opt.xAxis = { type: 'value', scale: true };
|
||||
opt.yAxis = { type: 'value', scale: true };
|
||||
opt.series = (d.data.series || []).map(function (s) {
|
||||
return { name: s.name, type: 'scatter', data: s.data, symbolSize: 10, label: labelCfg(!!d.show_label) };
|
||||
});
|
||||
return opt;
|
||||
},
|
||||
gauge: function (d) {
|
||||
var opt = baseOption(d.title);
|
||||
delete opt.grid;
|
||||
opt.tooltip = { trigger: 'item' };
|
||||
var s0 = (d.data.series || [])[0] || {};
|
||||
var val = (s0.data && s0.data[0]) || 0;
|
||||
if (typeof val !== 'number') val = 0;
|
||||
opt.series = [{
|
||||
type: 'gauge', center: ['50%','60%'], startAngle: 200, endAngle: -20,
|
||||
min: 0, max: 100,
|
||||
detail: { formatter: '{value}%', fontSize: 24, offsetCenter: [0,'60%'] },
|
||||
data: [{ value: val, name: s0.name || d.title }],
|
||||
axisLine: { lineStyle: { width: 20 } },
|
||||
progress: { show: true, width: 20 },
|
||||
pointer: { show: true }
|
||||
}];
|
||||
return opt;
|
||||
}
|
||||
};
|
||||
|
||||
function render(payload) {
|
||||
var theme = payload.theme === 'dark' ? 'dark' : null;
|
||||
var bgColor = payload.theme === 'dark' ? '#1a1a2e' : '#f8fafc';
|
||||
document.body.style.background = bgColor;
|
||||
|
||||
var el = document.getElementById('chart');
|
||||
if (chartInstance) chartInstance.dispose();
|
||||
chartInstance = echarts.init(el, theme);
|
||||
|
||||
var builder = builders[payload.chart_type] || builders.line;
|
||||
chartInstance.setOption(builder(payload));
|
||||
|
||||
window.addEventListener('resize', function () { chartInstance && chartInstance.resize(); });
|
||||
}
|
||||
|
||||
/* MCP Apps postMessage protocol */
|
||||
window.addEventListener('message', function (event) {
|
||||
var msg = event.data;
|
||||
if (msg && msg.type === 'mcp-app-data') {
|
||||
render(msg.payload);
|
||||
}
|
||||
});
|
||||
|
||||
/* Signal readiness to host */
|
||||
window.parent.postMessage({ type: 'mcp-app-ready' }, '*');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
60
skills/common/data-dashboard/apps/metrics.html
Normal file
60
skills/common/data-dashboard/apps/metrics.html
Normal file
@ -0,0 +1,60 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Metrics</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f8fafc; padding: 24px; }
|
||||
h1 { font-size: 20px; font-weight: 600; color: #0f172a; margin-bottom: 20px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }
|
||||
.card { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 20px; transition: box-shadow 0.15s; }
|
||||
.card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.06); }
|
||||
.card-label { font-size: 13px; color: #64748b; margin-bottom: 8px; }
|
||||
.card-value { font-size: 28px; font-weight: 700; color: #0f172a; }
|
||||
.card-change { margin-top: 8px; font-size: 13px; font-weight: 500; }
|
||||
.arrow { font-size: 10px; }
|
||||
</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 render(payload) {
|
||||
document.getElementById('title').textContent = payload.title || 'Dashboard';
|
||||
var grid = document.getElementById('grid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
(payload.metrics || []).forEach(function (m) {
|
||||
var changeHtml = '';
|
||||
if (m.change) {
|
||||
var ct = m.change_type || 'neutral';
|
||||
var color = ct === 'up' ? '#16a34a' : ct === 'down' ? '#dc2626' : '#6b7280';
|
||||
var arrow = ct === 'up' ? '\u25B2' : ct === 'down' ? '\u25BC' : '\u2014';
|
||||
changeHtml = '<div class="card-change"><span class="arrow" style="color:' + color + '">' + arrow + '</span> <span style="color:' + color + '">' + esc(m.change) + '</span></div>';
|
||||
}
|
||||
var card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
card.innerHTML = '<div class="card-label">' + esc(m.label || '') + '</div>'
|
||||
+ '<div class="card-value">' + esc(m.value || '') + '</div>'
|
||||
+ changeHtml;
|
||||
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>
|
||||
178
skills/common/data-dashboard/apps/multi-chart.html
Normal file
178
skills/common/data-dashboard/apps/multi-chart.html
Normal file
@ -0,0 +1,178 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Multi Chart</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 24px; }
|
||||
h1 { font-size: 22px; font-weight: 600; margin-bottom: 20px; text-align: center; }
|
||||
.grid { display: grid; gap: 16px; }
|
||||
.chart-card { border-radius: 12px; padding: 16px; border: 1px solid #e2e8f0; }
|
||||
@media (max-width: 768px) { .grid { grid-template-columns: 1fr !important; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 id="title"></h1>
|
||||
<div class="grid" id="grid"></div>
|
||||
<script>
|
||||
(function () {
|
||||
var charts = [];
|
||||
var COLOR_PALETTE = ['#5470c6','#91cc75','#fac858','#ee6666','#73c0de','#3ba272','#fc8452','#9a60b4'];
|
||||
|
||||
function baseOption(title) {
|
||||
return {
|
||||
title: { text: title, left: 'center', top: 8, textStyle: { fontSize: 16, fontWeight: 600 } },
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { top: 36 },
|
||||
grid: { top: 80, left: 60, right: 30, bottom: 40 },
|
||||
color: COLOR_PALETTE
|
||||
};
|
||||
}
|
||||
function labelCfg(show) { return { show: show, position: 'top', fontSize: 11 }; }
|
||||
|
||||
var builders = {
|
||||
line: function (d) {
|
||||
var opt = baseOption(d.title);
|
||||
opt.xAxis = { type: 'category', data: d.data.categories || [], boundaryGap: false };
|
||||
opt.yAxis = { type: 'value' };
|
||||
opt.series = (d.data.series || []).map(function (s) {
|
||||
return { name: s.name, type: 'line', data: s.data, smooth: !!d.smooth,
|
||||
stack: d.stacked ? 'total' : null, areaStyle: d.stacked ? {} : null,
|
||||
label: labelCfg(!!d.show_label) };
|
||||
});
|
||||
return opt;
|
||||
},
|
||||
bar: function (d) {
|
||||
var opt = baseOption(d.title);
|
||||
opt.xAxis = { type: 'category', data: d.data.categories || [] };
|
||||
opt.yAxis = { type: 'value' };
|
||||
opt.series = (d.data.series || []).map(function (s) {
|
||||
return { name: s.name, type: 'bar', data: s.data, stack: d.stacked ? 'total' : null,
|
||||
label: labelCfg(!!d.show_label), barMaxWidth: 50 };
|
||||
});
|
||||
return opt;
|
||||
},
|
||||
pie: function (d) {
|
||||
var opt = baseOption(d.title);
|
||||
delete opt.grid;
|
||||
opt.tooltip = { trigger: 'item', formatter: '{b}: {c} ({d}%)' };
|
||||
var s0 = (d.data.series || [])[0] || {};
|
||||
opt.series = [{
|
||||
name: s0.name || d.title, type: 'pie', radius: ['40%','70%'], center: ['50%','55%'],
|
||||
data: s0.data || [],
|
||||
emphasis: { itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0,0,0,0.5)' } },
|
||||
label: { show: true, formatter: '{b}: {d}%' },
|
||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 }
|
||||
}];
|
||||
return opt;
|
||||
},
|
||||
radar: function (d) {
|
||||
var opt = baseOption(d.title);
|
||||
delete opt.grid;
|
||||
opt.tooltip = { trigger: 'item' };
|
||||
var cats = d.data.categories || [];
|
||||
var series = d.data.series || [];
|
||||
var maxVals = cats.map(function () { return 0; });
|
||||
series.forEach(function (s) {
|
||||
s.data.forEach(function (v, i) { if (i < maxVals.length && v > maxVals[i]) maxVals[i] = v; });
|
||||
});
|
||||
opt.radar = {
|
||||
indicator: cats.map(function (c, i) { return { name: c, max: Math.round(maxVals[i] * 1.2) || 100 }; }),
|
||||
center: ['50%','55%']
|
||||
};
|
||||
opt.series = [{
|
||||
type: 'radar',
|
||||
data: series.map(function (s) { return { value: s.data, name: s.name }; }),
|
||||
areaStyle: { opacity: 0.15 }
|
||||
}];
|
||||
return opt;
|
||||
},
|
||||
scatter: function (d) {
|
||||
var opt = baseOption(d.title);
|
||||
opt.tooltip = { trigger: 'item', formatter: '{a}: ({c})' };
|
||||
opt.xAxis = { type: 'value', scale: true };
|
||||
opt.yAxis = { type: 'value', scale: true };
|
||||
opt.series = (d.data.series || []).map(function (s) {
|
||||
return { name: s.name, type: 'scatter', data: s.data, symbolSize: 10, label: labelCfg(!!d.show_label) };
|
||||
});
|
||||
return opt;
|
||||
},
|
||||
gauge: function (d) {
|
||||
var opt = baseOption(d.title);
|
||||
delete opt.grid;
|
||||
opt.tooltip = { trigger: 'item' };
|
||||
var s0 = (d.data.series || [])[0] || {};
|
||||
var val = (s0.data && s0.data[0]) || 0;
|
||||
if (typeof val !== 'number') val = 0;
|
||||
opt.series = [{
|
||||
type: 'gauge', center: ['50%','60%'], startAngle: 200, endAngle: -20,
|
||||
min: 0, max: 100,
|
||||
detail: { formatter: '{value}%', fontSize: 24, offsetCenter: [0,'60%'] },
|
||||
data: [{ value: val, name: s0.name || d.title }],
|
||||
axisLine: { lineStyle: { width: 20 } },
|
||||
progress: { show: true, width: 20 },
|
||||
pointer: { show: true }
|
||||
}];
|
||||
return opt;
|
||||
}
|
||||
};
|
||||
|
||||
function render(payload) {
|
||||
var isDark = payload.theme === 'dark';
|
||||
document.body.style.background = isDark ? '#1a1a2e' : '#f8fafc';
|
||||
document.body.style.color = isDark ? '#e0e0e0' : '#0f172a';
|
||||
|
||||
document.getElementById('title').textContent = payload.title || 'Dashboard';
|
||||
|
||||
var grid = document.getElementById('grid');
|
||||
var columns = payload.columns || 2;
|
||||
grid.style.gridTemplateColumns = 'repeat(' + columns + ', 1fr)';
|
||||
grid.innerHTML = '';
|
||||
|
||||
// Dispose previous charts
|
||||
charts.forEach(function (c) { c.dispose(); });
|
||||
charts = [];
|
||||
|
||||
var echartsTheme = isDark ? 'dark' : null;
|
||||
var cardBg = isDark ? '#252547' : '#ffffff';
|
||||
var borderColor = isDark ? '#3a3a5c' : '#e2e8f0';
|
||||
|
||||
(payload.charts || []).forEach(function (chartDef, idx) {
|
||||
var card = document.createElement('div');
|
||||
card.className = 'chart-card';
|
||||
card.style.background = cardBg;
|
||||
card.style.borderColor = borderColor;
|
||||
|
||||
var el = document.createElement('div');
|
||||
el.id = 'chart_' + idx;
|
||||
el.style.width = '100%';
|
||||
el.style.height = chartDef.height || '350px';
|
||||
card.appendChild(el);
|
||||
grid.appendChild(card);
|
||||
|
||||
var c = echarts.init(el, echartsTheme);
|
||||
var builder = builders[chartDef.chart_type] || builders.line;
|
||||
c.setOption(builder(chartDef));
|
||||
charts.push(c);
|
||||
});
|
||||
|
||||
window.addEventListener('resize', function () {
|
||||
charts.forEach(function (c) { c.resize(); });
|
||||
});
|
||||
}
|
||||
|
||||
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>
|
||||
@ -1,105 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Data Dashboard MCP Server - renders metric data as an interactive dashboard.
|
||||
Returns UIResource via the mcp-ui protocol so the frontend renders it as an iframe.
|
||||
Data Dashboard 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
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from mcp_ui_server import create_ui_resource, UIMetadataKey
|
||||
import os
|
||||
from typing import Any, Dict
|
||||
|
||||
from mcp_common import (
|
||||
create_error_response,
|
||||
create_initialize_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")
|
||||
|
||||
def _build_dashboard_html(title: str, metrics: List[Dict[str, str]]) -> str:
|
||||
"""Build a self-contained HTML dashboard from metrics data."""
|
||||
cards_html = ""
|
||||
for m in metrics:
|
||||
label = _esc(m.get("label", ""))
|
||||
value = _esc(m.get("value", ""))
|
||||
change = m.get("change", "")
|
||||
change_type = m.get("change_type", "neutral")
|
||||
# Resource URI -> static HTML App file mapping
|
||||
RESOURCE_MAP = {
|
||||
"ui://data-dashboard/metrics": "metrics.html",
|
||||
"ui://data-dashboard/chart": "chart.html",
|
||||
"ui://data-dashboard/multi-chart": "multi-chart.html",
|
||||
}
|
||||
|
||||
change_html = ""
|
||||
if change:
|
||||
color = "#16a34a" if change_type == "up" else "#dc2626" if change_type == "down" else "#6b7280"
|
||||
arrow = "▲" if change_type == "up" else "▼" if change_type == "down" else "—"
|
||||
change_html = f'<span class="change" style="color:{color}"><span class="arrow">{arrow}</span> {_esc(change)}</span>'
|
||||
|
||||
cards_html += f"""
|
||||
<div class="card">
|
||||
<div class="card-label">{label}</div>
|
||||
<div class="card-value">{value}</div>
|
||||
{f'<div class="card-change">{change_html}</div>' if change_html else ''}
|
||||
</div>"""
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{_esc(title)}</title>
|
||||
<style>
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f8fafc; padding: 24px; }}
|
||||
h1 {{ font-size: 20px; font-weight: 600; color: #0f172a; margin-bottom: 20px; }}
|
||||
.grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }}
|
||||
.card {{ background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 20px; transition: box-shadow 0.15s; }}
|
||||
.card:hover {{ box-shadow: 0 4px 12px rgba(0,0,0,0.06); }}
|
||||
.card-label {{ font-size: 13px; color: #64748b; margin-bottom: 8px; }}
|
||||
.card-value {{ font-size: 28px; font-weight: 700; color: #0f172a; }}
|
||||
.card-change {{ margin-top: 8px; font-size: 13px; font-weight: 500; }}
|
||||
.arrow {{ font-size: 10px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{_esc(title)}</h1>
|
||||
<div class="grid">{cards_html}
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
RESOURCE_DEFINITIONS = [
|
||||
{
|
||||
"uri": "ui://data-dashboard/metrics",
|
||||
"name": "metrics-dashboard",
|
||||
"title": "Metrics Dashboard",
|
||||
"description": "Renders KPI metric cards",
|
||||
"mimeType": RESOURCE_MIME_TYPE,
|
||||
},
|
||||
{
|
||||
"uri": "ui://data-dashboard/chart",
|
||||
"name": "chart",
|
||||
"title": "Chart",
|
||||
"description": "Renders a single ECharts chart",
|
||||
"mimeType": RESOURCE_MIME_TYPE,
|
||||
},
|
||||
{
|
||||
"uri": "ui://data-dashboard/multi-chart",
|
||||
"name": "multi-chart",
|
||||
"title": "Multi Chart",
|
||||
"description": "Renders multiple ECharts charts in a grid",
|
||||
"mimeType": RESOURCE_MIME_TYPE,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _esc(text: str) -> str:
|
||||
"""Minimal HTML escaping."""
|
||||
return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
||||
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 render_dashboard(title: str, metrics: List[Dict[str, str]]) -> Dict[str, Any]:
|
||||
"""Create a UIResource dashboard and serialize it as JSON for TOOL_RESPONSE."""
|
||||
try:
|
||||
html = _build_dashboard_html(title, metrics)
|
||||
uri_slug = title.replace(" ", "-").lower()[:50]
|
||||
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.
|
||||
|
||||
ui_resource = create_ui_resource(
|
||||
{
|
||||
"uri": f"ui://data-dashboard/{uri_slug}",
|
||||
"content": {"type": "rawHtml", "htmlString": html},
|
||||
"encoding": "text",
|
||||
"uiMetadata": {
|
||||
UIMetadataKey.PREFERRED_FRAME_SIZE: ["100%", "auto"],
|
||||
},
|
||||
}
|
||||
)
|
||||
Returns a JSON payload with type:"app" that tells the host to:
|
||||
1. Fetch the static HTML App via resources/read(resourceUri)
|
||||
2. Render it in a sandboxed iframe
|
||||
3. Send `data` to the iframe via postMessage
|
||||
"""
|
||||
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}]}
|
||||
|
||||
resource_json = json.dumps(
|
||||
ui_resource.model_dump(mode="json"), ensure_ascii=False
|
||||
)
|
||||
return {"content": [{"type": "text", "text": resource_json}]}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"content": [{"type": "text", "text": f"Error creating dashboard: {str(e)}"}]
|
||||
}
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool handlers — return data only, no HTML generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _handle_render_metrics(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||
title = arguments.get("title", "Dashboard")
|
||||
metrics = arguments.get("metrics", [])
|
||||
if not metrics:
|
||||
raise ValueError("Missing required parameter: metrics")
|
||||
return _create_app_response(
|
||||
"ui://data-dashboard/metrics",
|
||||
{"title": title, "metrics": metrics},
|
||||
"100%", "auto",
|
||||
)
|
||||
|
||||
|
||||
def _handle_render_chart(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||
title = arguments.get("title", "Chart")
|
||||
chart_type = arguments.get("chart_type")
|
||||
chart_data = arguments.get("data", {})
|
||||
if not chart_type:
|
||||
raise ValueError("Missing required parameter: chart_type")
|
||||
if not chart_data or not chart_data.get("series"):
|
||||
raise ValueError("Missing required parameter: data.series")
|
||||
|
||||
width = arguments.get("width", "100%")
|
||||
height = arguments.get("height", "400px")
|
||||
|
||||
return _create_app_response(
|
||||
"ui://data-dashboard/chart",
|
||||
{
|
||||
"title": title,
|
||||
"chart_type": chart_type,
|
||||
"data": chart_data,
|
||||
"theme": arguments.get("theme", "light"),
|
||||
"stacked": arguments.get("stacked", False),
|
||||
"smooth": arguments.get("smooth", False),
|
||||
"show_label": arguments.get("show_label", False),
|
||||
},
|
||||
width, height,
|
||||
)
|
||||
|
||||
|
||||
def _handle_render_multi_chart(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||
title = arguments.get("title", "Dashboard")
|
||||
charts = arguments.get("charts", [])
|
||||
if not charts:
|
||||
raise ValueError("Missing required parameter: charts")
|
||||
for i, c in enumerate(charts):
|
||||
if not c.get("chart_type"):
|
||||
raise ValueError(f"charts[{i}]: missing chart_type")
|
||||
if not c.get("data", {}).get("series"):
|
||||
raise ValueError(f"charts[{i}]: missing data.series")
|
||||
|
||||
columns = arguments.get("columns", 2)
|
||||
num_rows = (len(charts) + columns - 1) // columns
|
||||
total_height = f"{80 + num_rows * 420}px"
|
||||
|
||||
return _create_app_response(
|
||||
"ui://data-dashboard/multi-chart",
|
||||
{
|
||||
"title": title,
|
||||
"charts": charts,
|
||||
"columns": columns,
|
||||
"theme": arguments.get("theme", "light"),
|
||||
},
|
||||
"100%", total_height,
|
||||
)
|
||||
|
||||
|
||||
TOOL_HANDLERS = {
|
||||
"render_metrics": _handle_render_metrics,
|
||||
"render_chart": _handle_render_chart,
|
||||
"render_multi_chart": _handle_render_multi_chart,
|
||||
}
|
||||
|
||||
|
||||
async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
|
||||
@ -110,7 +170,21 @@ async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
|
||||
request_id = request.get("id")
|
||||
|
||||
if method == "initialize":
|
||||
return create_initialize_response(request_id, "data-dashboard")
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {
|
||||
"tools": {},
|
||||
"resources": {},
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": "data-dashboard",
|
||||
"version": "3.0.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
elif method == "ping":
|
||||
return create_ping_response(request_id)
|
||||
@ -120,57 +194,63 @@ async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not tools:
|
||||
tools = [
|
||||
{
|
||||
"name": "render_dashboard",
|
||||
"description": "Render a data dashboard with metric cards.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"metrics": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {"type": "string"},
|
||||
"value": {"type": "string"},
|
||||
"change": {"type": "string"},
|
||||
"change_type": {"type": "string", "enum": ["up", "down", "neutral"]},
|
||||
},
|
||||
"required": ["label", "value"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["title", "metrics"],
|
||||
},
|
||||
"name": name,
|
||||
"description": f"Render {name.replace('render_', '')}",
|
||||
"inputSchema": {"type": "object", "properties": {}, "required": []},
|
||||
"_meta": {"ui": {"resourceUri": uri}},
|
||||
}
|
||||
for name, uri in [
|
||||
("render_metrics", "ui://data-dashboard/metrics"),
|
||||
("render_chart", "ui://data-dashboard/chart"),
|
||||
("render_multi_chart", "ui://data-dashboard/multi-chart"),
|
||||
]
|
||||
]
|
||||
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", {})
|
||||
|
||||
if tool_name == "render_dashboard":
|
||||
title = arguments.get("title", "Dashboard")
|
||||
metrics = arguments.get("metrics", [])
|
||||
handler = TOOL_HANDLERS.get(tool_name)
|
||||
if not handler:
|
||||
return create_error_response(request_id, -32601, f"Unknown tool: {tool_name}")
|
||||
|
||||
if not metrics:
|
||||
return create_error_response(
|
||||
request_id, -32602, "Missing required parameter: metrics"
|
||||
)
|
||||
try:
|
||||
result = handler(arguments)
|
||||
except ValueError as e:
|
||||
return create_error_response(request_id, -32602, str(e))
|
||||
|
||||
result = render_dashboard(title, metrics)
|
||||
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
||||
|
||||
else:
|
||||
return create_error_response(
|
||||
request_id, -32601, f"Unknown tool: {tool_name}"
|
||||
)
|
||||
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
||||
|
||||
else:
|
||||
return create_error_response(
|
||||
request_id, -32601, f"Unknown method: {method}"
|
||||
)
|
||||
return create_error_response(request_id, -32601, f"Unknown method: {method}")
|
||||
|
||||
except Exception as e:
|
||||
return create_error_response(
|
||||
@ -179,7 +259,6 @@ async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main entry point."""
|
||||
await handle_mcp_streaming(handle_request)
|
||||
|
||||
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
[
|
||||
{
|
||||
"name": "render_dashboard",
|
||||
"description": "Render an interactive data dashboard with metric cards. Pass an array of metrics, each with label, value, and optional change/change_type fields. The dashboard is rendered as an HTML card UI.",
|
||||
"name": "render_metrics",
|
||||
"description": "Render a metric card dashboard showing KPIs, stats, and numerical summaries.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Dashboard title, e.g. 'Sales Overview'"
|
||||
"description": "Title displayed at the top of the dashboard"
|
||||
},
|
||||
"metrics": {
|
||||
"type": "array",
|
||||
@ -15,29 +15,167 @@
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Metric name, e.g. 'Revenue'"
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"description": "Metric value, e.g. '$12,345'"
|
||||
},
|
||||
"change": {
|
||||
"type": "string",
|
||||
"description": "Change indicator, e.g. '+12.5%' or '-3.2%'"
|
||||
},
|
||||
"change_type": {
|
||||
"type": "string",
|
||||
"enum": ["up", "down", "neutral"],
|
||||
"description": "Direction of change"
|
||||
}
|
||||
"label": { "type": "string", "description": "Metric name" },
|
||||
"value": { "type": "string", "description": "Metric value" },
|
||||
"change": { "type": "string", "description": "Change indicator, e.g. '+12.5%'" },
|
||||
"change_type": { "type": "string", "enum": ["up", "down", "neutral"], "description": "Direction of change" }
|
||||
},
|
||||
"required": ["label", "value"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["title", "metrics"]
|
||||
},
|
||||
"_meta": {
|
||||
"ui": {
|
||||
"resourceUri": "ui://data-dashboard/metrics"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "render_chart",
|
||||
"description": "Render a single ECharts chart. Supports line, bar, pie, radar, scatter, and gauge chart types.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Chart title"
|
||||
},
|
||||
"chart_type": {
|
||||
"type": "string",
|
||||
"enum": ["line", "bar", "pie", "radar", "scatter", "gauge"],
|
||||
"description": "Type of chart to render"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"description": "Chart data object",
|
||||
"properties": {
|
||||
"categories": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "X-axis labels (for line, bar, radar)"
|
||||
},
|
||||
"series": {
|
||||
"type": "array",
|
||||
"description": "Array of data series",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string", "description": "Series name" },
|
||||
"data": { "type": "array", "description": "Data values" }
|
||||
},
|
||||
"required": ["name", "data"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["series"]
|
||||
},
|
||||
"width": {
|
||||
"type": "string",
|
||||
"description": "CSS width. Default: '100%'",
|
||||
"default": "100%"
|
||||
},
|
||||
"height": {
|
||||
"type": "string",
|
||||
"description": "CSS height. Default: '400px'",
|
||||
"default": "400px"
|
||||
},
|
||||
"stacked": {
|
||||
"type": "boolean",
|
||||
"description": "Stack series (line/bar). Default: false",
|
||||
"default": false
|
||||
},
|
||||
"smooth": {
|
||||
"type": "boolean",
|
||||
"description": "Smooth curves (line). Default: false",
|
||||
"default": false
|
||||
},
|
||||
"show_label": {
|
||||
"type": "boolean",
|
||||
"description": "Show data labels. Default: false",
|
||||
"default": false
|
||||
},
|
||||
"theme": {
|
||||
"type": "string",
|
||||
"enum": ["light", "dark"],
|
||||
"description": "Color theme. Default: 'light'",
|
||||
"default": "light"
|
||||
}
|
||||
},
|
||||
"required": ["title", "chart_type", "data"]
|
||||
},
|
||||
"_meta": {
|
||||
"ui": {
|
||||
"resourceUri": "ui://data-dashboard/chart"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "render_multi_chart",
|
||||
"description": "Render multiple ECharts charts in a grid layout. Use for comprehensive overviews and business reports.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Overall dashboard title"
|
||||
},
|
||||
"charts": {
|
||||
"type": "array",
|
||||
"description": "Array of chart objects",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": { "type": "string", "description": "Chart title" },
|
||||
"chart_type": { "type": "string", "enum": ["line", "bar", "pie", "radar", "scatter", "gauge"], "description": "Type of chart" },
|
||||
"data": {
|
||||
"type": "object",
|
||||
"description": "Chart data",
|
||||
"properties": {
|
||||
"categories": { "type": "array", "items": { "type": "string" } },
|
||||
"series": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"data": { "type": "array" }
|
||||
},
|
||||
"required": ["name", "data"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["series"]
|
||||
},
|
||||
"height": { "type": "string", "default": "350px" },
|
||||
"stacked": { "type": "boolean", "default": false },
|
||||
"smooth": { "type": "boolean", "default": false },
|
||||
"show_label": { "type": "boolean", "default": false }
|
||||
},
|
||||
"required": ["title", "chart_type", "data"]
|
||||
}
|
||||
},
|
||||
"columns": {
|
||||
"type": "integer",
|
||||
"description": "Grid columns (1-4). Default: 2",
|
||||
"default": 2,
|
||||
"minimum": 1,
|
||||
"maximum": 4
|
||||
},
|
||||
"theme": {
|
||||
"type": "string",
|
||||
"enum": ["light", "dark"],
|
||||
"description": "Color theme. Default: 'light'",
|
||||
"default": "light"
|
||||
}
|
||||
},
|
||||
"required": ["title", "charts"]
|
||||
},
|
||||
"_meta": {
|
||||
"ui": {
|
||||
"resourceUri": "ui://data-dashboard/multi-chart"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@ -1,13 +1,188 @@
|
||||
## `render_dashboard` usage guide
|
||||
## Data Visualization Tools Usage Guide
|
||||
|
||||
### When to use render_dashboard:
|
||||
- When the user wants to visualize data as metric cards (KPIs, stats, numbers)
|
||||
- When summarizing numerical results (sales, traffic, performance metrics)
|
||||
- When comparing multiple data points side by side
|
||||
Three tools are available for rendering data visualizations:
|
||||
- `render_metrics` — Metric card dashboard (KPIs, stats)
|
||||
- `render_chart` — Single chart (line, bar, pie, radar, scatter, gauge)
|
||||
- `render_multi_chart` — Multiple charts in a grid layout
|
||||
|
||||
### How to call:
|
||||
render_dashboard(title="Sales Overview", metrics=[
|
||||
---
|
||||
|
||||
### 1. render_metrics
|
||||
|
||||
When to use: KPIs, stats, numerical summaries, side-by-side comparisons.
|
||||
|
||||
```
|
||||
render_metrics(
|
||||
title="Sales Overview",
|
||||
metrics=[
|
||||
{"label": "Revenue", "value": "$12,345", "change": "+12.5%", "change_type": "up"},
|
||||
{"label": "Users", "value": "1,234", "change": "-3.2%", "change_type": "down"},
|
||||
{"label": "Conversion", "value": "4.5%", "change_type": "neutral"}
|
||||
])
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. render_chart
|
||||
|
||||
When to use: trends, comparisons, distributions, compositions, single metric gauges.
|
||||
|
||||
#### Line chart — trends over time
|
||||
```
|
||||
render_chart(
|
||||
title="Monthly Revenue",
|
||||
chart_type="line",
|
||||
data={
|
||||
"categories": ["Jan", "Feb", "Mar", "Apr", "May", "Jun"],
|
||||
"series": [
|
||||
{"name": "2024", "data": [820, 932, 901, 934, 1290, 1330]},
|
||||
{"name": "2025", "data": [620, 732, 801, 1034, 1190, 1530]}
|
||||
]
|
||||
},
|
||||
smooth=true
|
||||
)
|
||||
```
|
||||
|
||||
#### Bar chart — comparisons
|
||||
```
|
||||
render_chart(
|
||||
title="Sales by Region",
|
||||
chart_type="bar",
|
||||
data={
|
||||
"categories": ["North", "South", "East", "West"],
|
||||
"series": [
|
||||
{"name": "Q1", "data": [320, 302, 341, 374]},
|
||||
{"name": "Q2", "data": [220, 182, 191, 234]}
|
||||
]
|
||||
},
|
||||
stacked=true,
|
||||
show_label=true
|
||||
)
|
||||
```
|
||||
|
||||
#### Pie chart — proportions
|
||||
```
|
||||
render_chart(
|
||||
title="Traffic Sources",
|
||||
chart_type="pie",
|
||||
data={
|
||||
"series": [{
|
||||
"name": "Sources",
|
||||
"data": [
|
||||
{"name": "Search", "value": 1048},
|
||||
{"name": "Direct", "value": 735},
|
||||
{"name": "Email", "value": 580},
|
||||
{"name": "Social", "value": 484}
|
||||
]
|
||||
}]
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
#### Radar chart — multi-dimensional
|
||||
```
|
||||
render_chart(
|
||||
title="Skill Assessment",
|
||||
chart_type="radar",
|
||||
data={
|
||||
"categories": ["Sales", "Admin", "Tech", "Support", "Marketing"],
|
||||
"series": [
|
||||
{"name": "Alice", "data": [4200, 3000, 20000, 35000, 50000]},
|
||||
{"name": "Bob", "data": [5000, 14000, 28000, 26000, 42000]}
|
||||
]
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
#### Scatter chart — correlation
|
||||
```
|
||||
render_chart(
|
||||
title="Height vs Weight",
|
||||
chart_type="scatter",
|
||||
data={
|
||||
"series": [
|
||||
{"name": "Male", "data": [[161, 51], [167, 59], [159, 49], [175, 73]]},
|
||||
{"name": "Female", "data": [[150, 45], [160, 55], [165, 60], [155, 50]]}
|
||||
]
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
#### Gauge chart — single KPI
|
||||
```
|
||||
render_chart(
|
||||
title="CPU Usage",
|
||||
chart_type="gauge",
|
||||
data={"series": [{"name": "CPU", "data": [72.5]}]}
|
||||
)
|
||||
```
|
||||
|
||||
#### Chart options:
|
||||
- `width`: CSS width, default "100%"
|
||||
- `height`: CSS height, default "400px"
|
||||
- `theme`: "light" (default) or "dark"
|
||||
- `stacked`: stack series (line/bar), default false
|
||||
- `smooth`: smooth curves (line), default false
|
||||
- `show_label`: show data labels, default false
|
||||
|
||||
---
|
||||
|
||||
### 3. render_multi_chart
|
||||
|
||||
When to use: comprehensive overviews, multiple related charts, business reports.
|
||||
|
||||
```
|
||||
render_multi_chart(
|
||||
title="Monthly Business Report",
|
||||
columns=2,
|
||||
theme="light",
|
||||
charts=[
|
||||
{
|
||||
"title": "Revenue Trend",
|
||||
"chart_type": "line",
|
||||
"data": {
|
||||
"categories": ["Jan", "Feb", "Mar", "Apr", "May", "Jun"],
|
||||
"series": [{"name": "Revenue", "data": [820, 932, 901, 934, 1290, 1330]}]
|
||||
},
|
||||
"smooth": true
|
||||
},
|
||||
{
|
||||
"title": "Sales by Region",
|
||||
"chart_type": "bar",
|
||||
"data": {
|
||||
"categories": ["North", "South", "East", "West"],
|
||||
"series": [
|
||||
{"name": "Q1", "data": [320, 302, 341, 374]},
|
||||
{"name": "Q2", "data": [220, 182, 191, 234]}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Traffic Sources",
|
||||
"chart_type": "pie",
|
||||
"data": {
|
||||
"series": [{"name": "Sources", "data": [
|
||||
{"name": "Search", "value": 1048},
|
||||
{"name": "Direct", "value": 735},
|
||||
{"name": "Social", "value": 484}
|
||||
]}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Server Load",
|
||||
"chart_type": "gauge",
|
||||
"data": {"series": [{"name": "CPU", "data": [67]}]},
|
||||
"height": "300px"
|
||||
}
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
### Tips:
|
||||
- Always choose the most appropriate chart_type for the data
|
||||
- Use line for time-series trends, bar for category comparisons
|
||||
- Use pie for proportions (limit ~7 segments), radar for multi-dimensional scores
|
||||
- Use scatter for correlation, gauge for single KPI (0-100)
|
||||
- Set height: "400px" for most charts, "300px" for gauge
|
||||
- Multi-chart: columns=2 for 2-4 charts, keep 4-6 max per call
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
103
skills/common/mcp-ui/apps/ask-user.html
Normal file
103
skills/common/mcp-ui/apps/ask-user.html
Normal file
@ -0,0 +1,103 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Ask User</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f8fafc; padding: 20px; }
|
||||
.question-block { margin-bottom: 20px; }
|
||||
.question-text { font-size: 15px; font-weight: 600; color: #0f172a; margin-bottom: 10px; }
|
||||
.options { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.option-btn { padding: 8px 16px; border: 1px solid #cbd5e1; border-radius: 8px; background: #fff;
|
||||
cursor: pointer; font-size: 14px; color: #334155; transition: all 0.15s; }
|
||||
.option-btn:hover { border-color: #3b82f6; color: #3b82f6; background: #eff6ff; }
|
||||
.option-btn.selected { border-color: #3b82f6; background: #3b82f6; color: #fff; }
|
||||
.submit-row { margin-top: 16px; text-align: right; }
|
||||
.submit-btn { padding: 10px 24px; border: none; border-radius: 8px; background: #3b82f6;
|
||||
color: #fff; font-size: 14px; font-weight: 600; cursor: pointer; }
|
||||
.submit-btn:hover { background: #2563eb; }
|
||||
.submit-btn:disabled { background: #94a3b8; cursor: not-allowed; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
(function () {
|
||||
var selections = {};
|
||||
|
||||
function esc(t) { var d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
|
||||
|
||||
function render(payload) {
|
||||
var root = document.getElementById('root');
|
||||
root.innerHTML = '';
|
||||
selections = {};
|
||||
|
||||
var questions = payload.questions || [];
|
||||
questions.forEach(function (q, qIdx) {
|
||||
var block = document.createElement('div');
|
||||
block.className = 'question-block';
|
||||
|
||||
var text = document.createElement('div');
|
||||
text.className = 'question-text';
|
||||
text.textContent = q.question;
|
||||
block.appendChild(text);
|
||||
|
||||
var optionsDiv = document.createElement('div');
|
||||
optionsDiv.className = 'options';
|
||||
|
||||
var multiSelect = !!q.multi_select;
|
||||
selections[qIdx] = multiSelect ? [] : null;
|
||||
|
||||
(q.options || []).forEach(function (opt, oIdx) {
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'option-btn';
|
||||
btn.textContent = opt;
|
||||
btn.addEventListener('click', function () {
|
||||
if (multiSelect) {
|
||||
var arr = selections[qIdx];
|
||||
var idx = arr.indexOf(opt);
|
||||
if (idx >= 0) { arr.splice(idx, 1); btn.classList.remove('selected'); }
|
||||
else { arr.push(opt); btn.classList.add('selected'); }
|
||||
} else {
|
||||
selections[qIdx] = opt;
|
||||
optionsDiv.querySelectorAll('.option-btn').forEach(function (b) { b.classList.remove('selected'); });
|
||||
btn.classList.add('selected');
|
||||
}
|
||||
});
|
||||
optionsDiv.appendChild(btn);
|
||||
});
|
||||
|
||||
block.appendChild(optionsDiv);
|
||||
root.appendChild(block);
|
||||
});
|
||||
|
||||
var submitRow = document.createElement('div');
|
||||
submitRow.className = 'submit-row';
|
||||
var submitBtn = document.createElement('button');
|
||||
submitBtn.className = 'submit-btn';
|
||||
submitBtn.textContent = 'Submit';
|
||||
submitBtn.addEventListener('click', function () {
|
||||
var answers = {};
|
||||
questions.forEach(function (q, qIdx) {
|
||||
answers[q.question] = selections[qIdx];
|
||||
});
|
||||
window.parent.postMessage({ type: 'mcp-app-response', payload: answers }, '*');
|
||||
});
|
||||
submitRow.appendChild(submitBtn);
|
||||
root.appendChild(submitRow);
|
||||
}
|
||||
|
||||
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>
|
||||
44
skills/common/mcp-ui/apps/html.html
Normal file
44
skills/common/mcp-ui/apps/html.html
Normal file
@ -0,0 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>HTML Renderer</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
(function () {
|
||||
function render(payload) {
|
||||
var root = document.getElementById('root');
|
||||
root.innerHTML = payload.html_content || '';
|
||||
|
||||
// Execute inline scripts that were injected via innerHTML
|
||||
var scripts = root.querySelectorAll('script');
|
||||
scripts.forEach(function (oldScript) {
|
||||
var newScript = document.createElement('script');
|
||||
if (oldScript.src) {
|
||||
newScript.src = oldScript.src;
|
||||
} else {
|
||||
newScript.textContent = oldScript.textContent;
|
||||
}
|
||||
oldScript.parentNode.replaceChild(newScript, oldScript);
|
||||
});
|
||||
}
|
||||
|
||||
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>
|
||||
33
skills/common/mcp-ui/apps/url.html
Normal file
33
skills/common/mcp-ui/apps/url.html
Normal file
@ -0,0 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>URL Embed</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body, html { width: 100%; height: 100%; overflow: hidden; }
|
||||
iframe { width: 100%; height: 100%; border: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<iframe id="frame" sandbox="allow-scripts allow-same-origin allow-forms"></iframe>
|
||||
<script>
|
||||
(function () {
|
||||
function render(payload) {
|
||||
var frame = document.getElementById('frame');
|
||||
frame.src = payload.url || '';
|
||||
}
|
||||
|
||||
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>
|
||||
@ -17,15 +17,11 @@ Example: If the question is "What is the topic of the PPT?", do NOT leave option
|
||||
|
||||
Do NOT call ask_user with empty options arrays.
|
||||
|
||||
### How to format options
|
||||
- Options MUST be placed in the `options` array field, NOT embedded in the question text.
|
||||
- Keep the question text short and clean — just the question itself.
|
||||
- Each question MUST have at least 2 options in the options array.
|
||||
|
||||
CORRECT example:
|
||||
### How to call ask_user
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
ask_user(
|
||||
title="A descriptive title",
|
||||
questions=[
|
||||
{
|
||||
"question": "Who is the audience?",
|
||||
"options": ["Leadership", "Team", "Client"]
|
||||
@ -35,9 +31,14 @@ CORRECT example:
|
||||
"options": ["5-10 minutes", "15-20 minutes", "30+ minutes"]
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### How to format options
|
||||
- Options MUST be placed in the `options` array field, NOT embedded in the question text.
|
||||
- Keep the question text short and clean — just the question itself.
|
||||
- Each question MUST have at least 2 options in the options array.
|
||||
|
||||
WRONG example (DO NOT do this):
|
||||
```json
|
||||
{
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "render_ui",
|
||||
"description": "Render an interactive UI widget in the chat. Supports two modes: (1) raw HTML — provide html_content to render custom HTML/CSS/JS, (2) external URL — provide url to embed an external webpage in an iframe. Use html_content OR url, not both.",
|
||||
"name": "render_html",
|
||||
"description": "Render custom HTML/CSS/JS content in the chat as an interactive UI widget.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -11,35 +11,73 @@
|
||||
},
|
||||
"html_content": {
|
||||
"type": "string",
|
||||
"description": "Complete HTML content to render. Can include inline CSS and JavaScript within <style> and <script> tags. Use this OR url, not both."
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "External URL to embed in an iframe. Use this OR html_content, not both."
|
||||
"description": "Complete HTML content to render. Can include inline CSS and JavaScript."
|
||||
},
|
||||
"width": {
|
||||
"type": "string",
|
||||
"description": "CSS width for the iframe. Default: '100%'",
|
||||
"description": "CSS width. Default: '100%'",
|
||||
"default": "100%"
|
||||
},
|
||||
"height": {
|
||||
"type": "string",
|
||||
"description": "CSS height for the iframe. Set to a fixed value (e.g. '300px', '600px') matching the HTML content height. Use 'auto' only when the HTML is responsive and has no fixed height. Default: 'auto'",
|
||||
"description": "CSS height. Default: 'auto'",
|
||||
"default": "auto"
|
||||
}
|
||||
},
|
||||
"required": ["title", "html_content"]
|
||||
},
|
||||
"_meta": {
|
||||
"ui": {
|
||||
"resourceUri": "ui://mcp-ui/html"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "render_url",
|
||||
"description": "Embed an external URL in an iframe in the chat.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "A descriptive title for the embedded content"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "External URL to embed in an iframe"
|
||||
},
|
||||
"width": {
|
||||
"type": "string",
|
||||
"description": "CSS width. Default: '100%'",
|
||||
"default": "100%"
|
||||
},
|
||||
"height": {
|
||||
"type": "string",
|
||||
"description": "CSS height. Default: 'auto'",
|
||||
"default": "auto"
|
||||
}
|
||||
},
|
||||
"required": ["title", "url"]
|
||||
},
|
||||
"_meta": {
|
||||
"ui": {
|
||||
"resourceUri": "ui://mcp-ui/url"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ask_user",
|
||||
"description": "Present questions with selectable options to the user. CRITICAL: Every question MUST have at least 2 options in the options array — generate reasonable suggestions based on context. Do NOT call this tool with empty options arrays. See the ask_user usage guide in system prompt for detailed rules.",
|
||||
"description": "Present questions with selectable options to the user. Each question MUST have at least 2 options.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "A descriptive title for the question panel"
|
||||
},
|
||||
"questions": {
|
||||
"type": "array",
|
||||
"description": "Array of questions to ask the user. Each question is an object with its own question text, options, and multi_select setting.",
|
||||
"description": "Array of questions to ask the user",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -50,14 +88,12 @@
|
||||
"options": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "REQUIRED array with at least 2 options. Put choices here, NOT in the question text."
|
||||
"items": { "type": "string" },
|
||||
"description": "REQUIRED array with at least 2 options"
|
||||
},
|
||||
"multi_select": {
|
||||
"type": "boolean",
|
||||
"description": "If true, the user can select multiple options for this question. Default: false.",
|
||||
"description": "If true, the user can select multiple options. Default: false",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
@ -65,7 +101,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["questions"]
|
||||
"required": ["title", "questions"]
|
||||
},
|
||||
"_meta": {
|
||||
"ui": {
|
||||
"resourceUri": "ui://mcp-ui/ask-user"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@ -1,84 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MCP UI Server - provides interactive UI rendering tools.
|
||||
MCP UI 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 sys
|
||||
import os
|
||||
from typing import Any, Dict
|
||||
|
||||
from mcp_ui_server import create_ui_resource, UIMetadataKey
|
||||
|
||||
from mcp_common import (
|
||||
create_error_response,
|
||||
create_initialize_response,
|
||||
create_ping_response,
|
||||
create_tools_list_response,
|
||||
load_tools_from_json,
|
||||
handle_mcp_streaming,
|
||||
)
|
||||
|
||||
RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"
|
||||
URI_MIME_TYPE = "text/uri-list"
|
||||
APPS_DIR = os.path.join(os.path.dirname(__file__), "apps")
|
||||
|
||||
def _serialize_ui_resource(ui_resource) -> str:
|
||||
"""Serialize a UIResource to JSON string."""
|
||||
return json.dumps(ui_resource.model_dump(mode="json"), ensure_ascii=False)
|
||||
# Resource URI -> static HTML App file mapping
|
||||
RESOURCE_MAP = {
|
||||
"ui://mcp-ui/html": "html.html",
|
||||
"ui://mcp-ui/url": "url.html",
|
||||
"ui://mcp-ui/ask-user": "ask-user.html",
|
||||
}
|
||||
|
||||
|
||||
def ask_user() -> Dict[str, Any]:
|
||||
"""Return a UIResource response for ask_user tool.
|
||||
|
||||
The actual questions are in the TOOL_CALL arguments. The frontend
|
||||
detects ui_type from the UIResource metadata and extracts content
|
||||
from the corresponding TOOL_CALL args.
|
||||
"""
|
||||
resource = create_ui_resource({
|
||||
RESOURCE_DEFINITIONS = [
|
||||
{
|
||||
"uri": "ui://mcp-ui/html",
|
||||
"name": "html-renderer",
|
||||
"title": "HTML Renderer",
|
||||
"description": "Renders custom HTML/CSS/JS content",
|
||||
"mimeType": RESOURCE_MIME_TYPE,
|
||||
},
|
||||
{
|
||||
"uri": "ui://mcp-ui/url",
|
||||
"name": "url-embed",
|
||||
"title": "URL Embed",
|
||||
"description": "Embeds an external URL in an iframe",
|
||||
"mimeType": URI_MIME_TYPE,
|
||||
},
|
||||
{
|
||||
"uri": "ui://mcp-ui/ask-user",
|
||||
"content": {"type": "rawHtml", "htmlString": "Questions sent to user."},
|
||||
"encoding": "text",
|
||||
"uiMetadata": {
|
||||
"type": "ask_user",
|
||||
"interactive": True,
|
||||
},
|
||||
})
|
||||
return {"content": [{"type": "text", "text": _serialize_ui_resource(resource)}]}
|
||||
"name": "ask-user",
|
||||
"title": "Ask User",
|
||||
"description": "Presents interactive questions with selectable options",
|
||||
"mimeType": RESOURCE_MIME_TYPE,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def render_ui(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Return a UIResource response for render_ui tool.
|
||||
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()
|
||||
|
||||
The actual html_content/url is in the TOOL_CALL arguments. The frontend
|
||||
detects ui_type from the UIResource metadata and extracts content
|
||||
from the corresponding TOOL_CALL args.
|
||||
|
||||
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.
|
||||
|
||||
Returns a JSON payload with type:"app" that tells the host to:
|
||||
1. Fetch the static HTML App via resources/read(resourceUri)
|
||||
2. Render it in a sandboxed iframe
|
||||
3. Send `data` to the iframe via postMessage
|
||||
"""
|
||||
html_content = arguments.get("html_content", "")
|
||||
url = arguments.get("url", "")
|
||||
width = arguments.get("width", "100%")
|
||||
height = arguments.get("height", "auto")
|
||||
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}]}
|
||||
|
||||
if html_content:
|
||||
resource = create_ui_resource({
|
||||
"uri": "ui://mcp-ui/render-ui",
|
||||
"content": {"type": "rawHtml", "htmlString": "UI rendered."},
|
||||
"encoding": "text",
|
||||
"uiMetadata": {
|
||||
UIMetadataKey.PREFERRED_FRAME_SIZE: [width, height],
|
||||
"type": "render_ui_html",
|
||||
"interactive": False,
|
||||
},
|
||||
})
|
||||
else:
|
||||
resource = create_ui_resource({
|
||||
"uri": "ui://mcp-ui/render-ui",
|
||||
"content": {"type": "externalUrl", "iframeUrl": url},
|
||||
"encoding": "text",
|
||||
"uiMetadata": {
|
||||
UIMetadataKey.PREFERRED_FRAME_SIZE: [width, height],
|
||||
"type": "render_ui_url",
|
||||
"interactive": False,
|
||||
},
|
||||
})
|
||||
return {"content": [{"type": "text", "text": _serialize_ui_resource(resource)}]}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool handlers — return data only
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _handle_render_html(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||
html_content = arguments.get("html_content", "")
|
||||
if not html_content:
|
||||
raise ValueError("Missing required parameter: html_content")
|
||||
return _create_app_response(
|
||||
"ui://mcp-ui/html",
|
||||
{"html_content": html_content},
|
||||
arguments.get("width", "100%"),
|
||||
arguments.get("height", "auto"),
|
||||
)
|
||||
|
||||
|
||||
def _handle_render_url(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||
url = arguments.get("url", "")
|
||||
if not url:
|
||||
raise ValueError("Missing required parameter: url")
|
||||
return _create_app_response(
|
||||
"ui://mcp-ui/url",
|
||||
{"url": url},
|
||||
arguments.get("width", "100%"),
|
||||
arguments.get("height", "auto"),
|
||||
)
|
||||
|
||||
|
||||
def _handle_ask_user(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||
questions = arguments.get("questions", [])
|
||||
if not questions:
|
||||
raise ValueError("Missing required parameter: questions")
|
||||
return _create_app_response(
|
||||
"ui://mcp-ui/ask-user",
|
||||
{"questions": questions},
|
||||
"100%", "auto",
|
||||
)
|
||||
|
||||
|
||||
TOOL_HANDLERS = {
|
||||
"render_html": _handle_render_html,
|
||||
"render_url": _handle_render_url,
|
||||
"ask_user": _handle_ask_user,
|
||||
}
|
||||
|
||||
|
||||
async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
|
||||
@ -89,7 +140,21 @@ async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
|
||||
request_id = request.get("id")
|
||||
|
||||
if method == "initialize":
|
||||
return create_initialize_response(request_id, "mcp-ui")
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {
|
||||
"tools": {},
|
||||
"resources": {},
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": "mcp-ui",
|
||||
"version": "3.0.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
elif method == "ping":
|
||||
return create_ping_response(request_id)
|
||||
@ -98,67 +163,67 @@ async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
|
||||
tools = load_tools_from_json("mcp_ui_tools.json")
|
||||
if not tools:
|
||||
tools = [
|
||||
{
|
||||
"name": "render_ui",
|
||||
"description": "Render an interactive UI widget in the chat.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "A descriptive title for the UI widget",
|
||||
},
|
||||
"html_content": {
|
||||
"type": "string",
|
||||
"description": "Complete HTML content to render. Use this OR url.",
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "External URL to embed. Use this OR html_content.",
|
||||
},
|
||||
},
|
||||
"required": ["title"],
|
||||
},
|
||||
}
|
||||
{"name": "render_html", "description": "Render custom HTML.",
|
||||
"inputSchema": {"type": "object", "properties": {"title": {"type": "string"}, "html_content": {"type": "string"}}, "required": ["title", "html_content"]},
|
||||
"_meta": {"ui": {"resourceUri": "ui://mcp-ui/html"}}},
|
||||
{"name": "render_url", "description": "Embed external URL.",
|
||||
"inputSchema": {"type": "object", "properties": {"title": {"type": "string"}, "url": {"type": "string"}}, "required": ["title", "url"]},
|
||||
"_meta": {"ui": {"resourceUri": "ui://mcp-ui/url"}}},
|
||||
{"name": "ask_user", "description": "Present questions with options.",
|
||||
"inputSchema": {"type": "object", "properties": {"title": {"type": "string"}, "questions": {"type": "array"}}, "required": ["title", "questions"]},
|
||||
"_meta": {"ui": {"resourceUri": "ui://mcp-ui/ask-user"}}},
|
||||
]
|
||||
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))
|
||||
mime = RESOURCE_MIME_TYPE
|
||||
for rd in RESOURCE_DEFINITIONS:
|
||||
if rd["uri"] == uri:
|
||||
mime = rd["mimeType"]
|
||||
break
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"contents": [
|
||||
{
|
||||
"uri": uri,
|
||||
"mimeType": mime,
|
||||
"text": html,
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
elif method == "tools/call":
|
||||
tool_name = params.get("name")
|
||||
arguments = params.get("arguments", {})
|
||||
|
||||
if tool_name == "render_ui":
|
||||
html_content = arguments.get("html_content", "")
|
||||
url = arguments.get("url", "")
|
||||
handler = TOOL_HANDLERS.get(tool_name)
|
||||
if not handler:
|
||||
return create_error_response(request_id, -32601, f"Unknown tool: {tool_name}")
|
||||
|
||||
if not html_content and not url:
|
||||
return create_error_response(
|
||||
request_id, -32602, "Missing required parameter: html_content or url"
|
||||
)
|
||||
try:
|
||||
result = handler(arguments)
|
||||
except ValueError as e:
|
||||
return create_error_response(request_id, -32602, str(e))
|
||||
|
||||
result = render_ui(arguments)
|
||||
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
||||
|
||||
elif tool_name == "ask_user":
|
||||
questions = arguments.get("questions", [])
|
||||
|
||||
if not questions:
|
||||
return create_error_response(
|
||||
request_id, -32602, "Missing required parameter: questions"
|
||||
)
|
||||
|
||||
result = ask_user()
|
||||
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
||||
|
||||
else:
|
||||
return create_error_response(
|
||||
request_id, -32601, f"Unknown tool: {tool_name}"
|
||||
)
|
||||
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
||||
|
||||
else:
|
||||
return create_error_response(
|
||||
request_id, -32601, f"Unknown method: {method}"
|
||||
)
|
||||
return create_error_response(request_id, -32601, f"Unknown method: {method}")
|
||||
|
||||
except Exception as e:
|
||||
return create_error_response(
|
||||
@ -167,7 +232,6 @@ async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main entry point."""
|
||||
await handle_mcp_streaming(handle_request)
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
30
skills/support/static-hosting/SKILL.md
Normal file
30
skills/support/static-hosting/SKILL.md
Normal file
@ -0,0 +1,30 @@
|
||||
---
|
||||
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
|
||||
|
||||
Host static files (HTML, CSS, JS, images, fonts, etc.) under `/workspace/` and get public URLs.
|
||||
|
||||
## Usage
|
||||
|
||||
Write files to `/workspace/`, then run the script to get the public URL:
|
||||
|
||||
```bash
|
||||
python3 {SKILL_DIR}/scripts/get_url.py <absolute_path>
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
python3 {SKILL_DIR}/scripts/get_url.py /workspace/index.html
|
||||
# => https://api-dev.gptbase.ai/robot-assets/[bot-id]/index.html
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Inside HTML, use **relative paths** to reference other assets (e.g. `href="css/style.css"`)
|
||||
- `/workspace/index.html` is auto-served at the directory URL
|
||||
- All files under `/robot-assets/` are publicly accessible, no authentication
|
||||
25
skills/support/static-hosting/scripts/get_url.py
Normal file
25
skills/support/static-hosting/scripts/get_url.py
Normal file
@ -0,0 +1,25 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
BACKEND_HOST = os.getenv("BACKEND_HOST", "https://api-dev.gptbase.ai")
|
||||
ASSISTANT_ID = os.getenv("ASSISTANT_ID", "")
|
||||
|
||||
if not ASSISTANT_ID:
|
||||
print("Error: ASSISTANT_ID environment variable is not set")
|
||||
sys.exit(1)
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: python3 {sys.argv[0]} <file_path>")
|
||||
print(f"Example: python3 {sys.argv[0]} /workspace/index.html")
|
||||
sys.exit(1)
|
||||
|
||||
file_path = os.path.abspath(sys.argv[1])
|
||||
|
||||
workspace_root = "/workspace"
|
||||
if not file_path.startswith(workspace_root):
|
||||
print(f"Error: path must be under {workspace_root}, got: {file_path}")
|
||||
sys.exit(1)
|
||||
|
||||
relative_path = file_path[len(workspace_root):] # e.g. "/css/style.css"
|
||||
base_url = f"{BACKEND_HOST.rstrip('/')}/robot-assets/{ASSISTANT_ID}"
|
||||
print(f"{base_url}{relative_path}")
|
||||
51
utils/daytona_file_fetcher.py
Normal file
51
utils/daytona_file_fetcher.py
Normal file
@ -0,0 +1,51 @@
|
||||
"""On-demand single file fetcher from Daytona sandbox."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from utils.settings import DAYTONA_ENABLED, DAYTONA_API_KEY, DAYTONA_SERVER_URL
|
||||
|
||||
logger = logging.getLogger('app')
|
||||
|
||||
REMOTE_WORKSPACE_ROOT = "/workspace"
|
||||
|
||||
|
||||
def fetch_file_from_daytona(bot_id: str, relative_path: str, local_path: Path) -> bool:
|
||||
"""Fetch a single file from the Daytona sandbox and save it locally.
|
||||
|
||||
Args:
|
||||
bot_id: The bot ID (sandbox is named "bot-{bot_id}")
|
||||
relative_path: Path relative to the bot's workspace root
|
||||
local_path: Local filesystem path where the file should be saved
|
||||
|
||||
Returns:
|
||||
True if the file was successfully fetched and saved, False otherwise
|
||||
"""
|
||||
if not (DAYTONA_ENABLED and DAYTONA_API_KEY and DAYTONA_SERVER_URL):
|
||||
return False
|
||||
|
||||
try:
|
||||
from daytona import Daytona, DaytonaConfig
|
||||
|
||||
config = DaytonaConfig(api_key=DAYTONA_API_KEY, api_url=DAYTONA_SERVER_URL)
|
||||
client = Daytona(config)
|
||||
|
||||
sandbox_name = f"bot-{bot_id}"
|
||||
sandbox = client.get(sandbox_name)
|
||||
|
||||
if sandbox.state not in ("Started", "Creating"):
|
||||
logger.warning(f"Sandbox {sandbox_name} not running (state={sandbox.state})")
|
||||
return False
|
||||
|
||||
remote_path = f"{REMOTE_WORKSPACE_ROOT}/{relative_path}"
|
||||
content = sandbox.fs.download_file(remote_path)
|
||||
|
||||
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_path.write_bytes(content)
|
||||
|
||||
logger.info(f"Fetched file from Daytona: {remote_path} -> {local_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch file from Daytona for bot {bot_id}: {e}")
|
||||
return False
|
||||
Loading…
Reference in New Issue
Block a user