opensensenova/sn-ppt-creative
| LLM / VLM calls go through sn-ppt-standard/lib/model_client.py (shared thin client). Text-to-image (the actual png rendering) goes through sn-image-base/scripts/sn_agent_runner.py. Falls back to web image search when T2I generation fails. Expects task_pack.json + info_pack.json already written by sn-ppt-entry.
npx skills add https://github.com/OpenSenseNova/SenseNova-Skills --skill sn-ppt-creative
> ⚠️ This skill must be invoked through /skill sn-ppt-entry. Never start here directly — the entry skill collects parameters and writes task_pack.json + info_pack.json that this skill requires. If you arrived here without those files, stop and tell the user to enter via /skill sn-ppt-entry or "生成 PPT".
| Kind | Backend |
|---|---|
| LLM (text) | $PPT_STANDARD_DIR/lib/model_client.py → llm(sys, user) |
| VLM (image understanding) | $PPT_STANDARD_DIR/lib/model_client.py → vlm(sys, user, images) |
| T2I (image generation) | $SN_IMAGE_BASE/scripts/sn_agent_runner.py sn-image-generate |
Never mix — LLM / VLM through sn-image-base, or T2I through model_client — both violate policy.
sn-search-image) as a fallback to find a real image that fits the page's topic. Each search result includes the image URL, source page, title, and domain for traceability.<deck_dir>/task_pack.json exists and ppt_mode == "creative"<deck_dir>/info_pack.json exists<deck_dir>/pages/ exists$SN_IMAGE_BASE env var (OpenClaw-injected) points at the sn-image-base skill root$PPT_STANDARD_DIR env var points at the sn-ppt-standard skill root (so we can import model_client)Any missing → stop and tell user to enter via /skill sn-ppt-entry.
sn-ppt-entry starts the generation progress WebUI after task_pack.json / info_pack.json are written. During creative-mode generation, publish progress with the shared writer from sn-ppt-standard:
P="python3 $PPT_STANDARD_DIR/scripts/progress_event.py"
$P --deck-dir <deck_dir> --stage creative-style --status running
$P --deck-dir <deck_dir> --stage creative-style --status ok --artifact style_spec.md
$P --deck-dir <deck_dir> --stage creative-outline --status running
$P --deck-dir <deck_dir> --stage creative-outline --status ok --artifact outline.json
$P --deck-dir <deck_dir> --stage creative-prompt --page N --status running
$P --deck-dir <deck_dir> --stage creative-prompt --page N --status ok
$P --deck-dir <deck_dir> --stage creative-render --page N --status running
$P --deck-dir <deck_dir> --stage creative-render --page N --status ok
$P --deck-dir <deck_dir> --stage export --status running
$P --deck-dir <deck_dir> --stage export --status ok
On failure, write the same stage with --status failed --error "<short reason>" before moving on or aborting. On native Windows, use python if python3 is unavailable.
python3 $SKILL_DIR/scripts/resume_scan.py --deck-dir <deck_dir>
# => {"style_spec_done": bool, "outline_done": bool, "pptx_done": bool,
# "pages": [{"page_no": 1, "action": "skip|render_only|full"}, ...]}
Dispatch:
| Manifest | Do |
|---|---|
| style_spec_done == false | Run Stage 2 |
| outline_done == false | Run Stage 3 |
| per-page action == "full" | Run Stage 4.1 + 4.2 |
| per-page action == "render_only" | Run Stage 4.2 only (prompt.txt already on disk) |
| per-page action == "skip" | Skip |
| pptx_done == false (all pages done or failed) | Run Stage 5 |
One independent exec tool_call. Two branches based on reference images.
Branch A (no ref images, or all missing on disk) — use model_client.llm:
python3 -c "
import sys, pathlib, json
sys.path.insert(0, '$PPT_STANDARD_DIR/lib')
from model_client import llm
deck = pathlib.Path('<deck_dir>')
tp = json.loads((deck / 'task_pack.json').read_text())
ip = json.loads((deck / 'info_pack.json').read_text())
sys_prompt = open('$SKILL_DIR/prompts/style_from_query.md').read()
user_prompt = json.dumps({
'params': tp['params'],
'query': ip.get('user_query'),
'digest': ip.get('document_digest'),
}, ensure_ascii=False)
md = llm(sys_prompt, user_prompt)
(deck / 'style_spec.md').write_text(md, encoding='utf-8')
print('style_spec.md ok')
"
Branch B (≥1 reference image on disk) — use model_client.vlm:
python3 -c "
import sys, pathlib, json
sys.path.insert(0, '$PPT_STANDARD_DIR/lib')
from model_client import vlm
deck = pathlib.Path('<deck_dir>')
ip = json.loads((deck / 'info_pack.json').read_text())
tp = json.loads((deck / 'task_pack.json').read_text())
refs = [p for p in (ip.get('user_assets') or {}).get('reference_images', []) if pathlib.Path(p).exists()]
sys_prompt = open('$SKILL_DIR/prompts/style_from_image.md').read()
user_prompt = f'PPT 主题/参数: {json.dumps(tp[\"params\"], ensure_ascii=False)}\nuser_query: {ip.get(\"user_query\") or \"\"}'
md = vlm(sys_prompt, user_prompt, images=refs)
(deck / 'style_spec.md').write_text(md, encoding='utf-8')
print(f'style_spec.md ok (from {len(refs)} ref images)')
"
If user_assets.reference_images is non-empty but all paths missing on disk: fall through to Branch A and prepend a line reference_images_missing: <original paths> at the top of style_spec.md.
python3 -c "
import sys, pathlib, json
sys.path.insert(0, '$PPT_STANDARD_DIR/lib')
from model_client import llm
deck = pathlib.Path('<deck_dir>')
tp = json.loads((deck / 'task_pack.json').read_text())
ip = json.loads((deck / 'info_pack.json').read_text())
style = (deck / 'style_spec.md').read_text()
sys_prompt = open('$SKILL_DIR/prompts/outline.md').read()
user_prompt = json.dumps({
'style_spec_markdown': style,
'params': tp['params'],
'query': ip.get('user_query'),
'digest': ip.get('document_digest'),
}, ensure_ascii=False)
raw = llm(sys_prompt, user_prompt).strip()
if raw.startswith('\`\`\`'):
raw = raw.split('\n', 1)[1].rsplit('\`\`\`', 1)[0]
data = json.loads(raw)
assert len(data['pages']) == tp['params']['page_count'], 'page_count mismatch'
(deck / 'outline.json').write_text(json.dumps(data, ensure_ascii=False, indent=2))
print(f'outline ok, {len(data[\"pages\"])} pages')
"
On failure (non-JSON / length mismatch): abort.
action == "render_only"python3 -c "
import sys, pathlib, json
sys.path.insert(0, '$PPT_STANDARD_DIR/lib')
from model_client import llm
deck = pathlib.Path('<deck_dir>')
N = <NNN>
style = (deck / 'style_spec.md').read_text()
outline = json.loads((deck / 'outline.json').read_text())
page = next(p for p in outline['pages'] if int(p['page_no']) == N)
sys_prompt = open('$SKILL_DIR/prompts/page_prompt.md').read()
user_prompt = json.dumps({'style_spec_markdown': style, 'page': page}, ensure_ascii=False)
txt = llm(sys_prompt, user_prompt)
(deck / 'pages' / f'page_{N:03d}.prompt.txt').write_text(txt, encoding='utf-8')
print(f'prompt page {N} ok')
"
# sanitize the written prompt in-place: strip hex/rgb/hsl/CSS/px/em/rem etc
# to prevent T2I server-side prompt-enhance from baking them into the image.
# Silent: no chat-facing notification; removals go to stderr only.
python3 $SKILL_DIR/scripts/sanitize_prompt.py --path <deck_dir>/pages/page_<NNN>.prompt.txt
--negative-prompt 是针对可能带自身 prompt-enhance 的 T2I 后端的最后一道防线:
即使前面的 sanitize 没拦住、或后端重写时引入了新的样式元数据,也通过反向约束压制模型把它们画出来。这段字符串在所有页上都一致。
python $SN_IMAGE_BASE/scripts/sn_agent_runner.py sn-image-generate \
--prompt "$(cat <deck_dir>/pages/page_<NNN>.prompt.txt)" \
--negative-prompt "hex color code, #RRGGBB, rgb(), rgba(), hsl(), hsla(), css, json, yaml, code snippet, pixel values, px, em, rem, pt, color palette text, typography label, design spec, style guide, font stack, hex code, layout annotation, dimensional callout, figma-style spec sheet, wireframe annotation, swatch with numbers" \
--aspect-ratio 16:9 \
--image-size 2k \
--save-path <deck_dir>/pages/page_<NNN>.png \
--output-format json
page_no into failed_pages, echo failure line, continue..prompt.txt may remain on disk for a later manual re-run of 4.2 only.所有页图生成后(含部分失败的情况),把 pages/page_*.png 平铺打包成 16:9 整册 PPTX,每张图满版一页。由 scripts/build_pptx.py 完成,模型只负责执行脚本。
python3 $SKILL_DIR/scripts/build_pptx.py --deck-dir <deck_dir>
# => {"deck_id": "...", "output": "<deck_dir>/<deck_id>.pptx",
# "total_slides": N, "included_pages": [...], "missing_pages": [...]}
行为约定:
<deck_dir>/<deck_id>.pptx;可用 --output 覆盖。outline.json 的 page_no 排;缺失 outline.json 时按 page_001..page_NNN 走。如果 python-pptx 缺失导致失败:🚫 不要尝试 pip install python-pptx
或任何替代方案。PNG 页面已经是最终交付物,直接进入 Stage 6。
Emit:
创意模式已完成。
📁 输出目录:<deck_dir>
📄 结果文件:
- style_spec.md
- outline.json
- pages/page_001.png ~ page_NNN.png(失败 M 页:page_..., page_...)
- <deck_id>.pptx(整册,缺失页插入空白)
⚠️ 未完成:
- page_007:生图返回超时,已跳过(pptx 中为空白页)
下一步:
- 可直接打开 <deck_id>.pptx 查看整册
- 或在 pages/ 目录查看 PNG
| Stage | Example |
|---|---|
| After resume_scan | 已进入 sn-ppt-creative,共 N 页 |
| After each progress write | .workbench/progress.json 已更新:<stage> <status> |
| After Stage 2 | [1] style_spec.md ✓ |
| After Stage 3 | [2] outline.json ✓(N 页) |
| Per page-prompt (4.1) | [prompt 3/10] ✓ |
| Per page-image (4.2) | [图 3/10] page_003.png ✓ or [图 3/10] ✗ 超时 |
| After Stage 5 | [pptx] <deck_id>.pptx ✓(N 页,缺失 M 页) or [pptx] ✗ <reason> |
| Closing | full summary above |
model_client.t2i — T2I must go through sn-image-base. model_client handles only LLM / VLM.sn-text-optimize or sn-image-recognize from sn-image-base — those must go through model_client.llm / model_client.vlm.scripts/build_pptx.py is the ONLY way to produce a PPTX. Never pip install python-pptx or write Node scripts that import pptxgenjs. If PPTX build fails, the PNG pages are the final deliverable.10. Wait for responses. If you ask the user a question, do NOT proceed until they reply. Never assume default values.
11. Multi-round edits: regenerate. When the user requests changes, re-run the affected pipeline stages. Do NOT sed/perl/patch files in-place.
12. Validate paths before writing. All output goes under <deck_dir>/ — the absolute path written in task_pack.json. Before writing any file, verify the parent directory exists. Never write to /workspace/, /tmp/, ~/, ./, or any hallucinated path.
Take opensensenova/sn-ppt-creative from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.
The instructions reference pip.
Without those the skill loads but fails at the first command.