Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/aiyinluya_sensenova-skills-studio.json

1 line
60 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "aiyinluya/sensenova-skills-studio", "files": {"sn_studio/core/config.py": {"f9bd94b067ccd5780e8b33f793d79e33ce512720": {"t": 1785768244.1674757, "n": 6517, "h": "b1a804b442aa5d3f", "c": "\"\"\"Load, validate, and persist .env configuration.\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport re\nfrom pathlib import Path\nfrom typing import Any\n\nfrom dotenv import dotenv_values, load_dotenv\n\nfrom sn_studio.core.paths import env_file, find_repo_root\n\n\ndef reload_env() -> Path:\n \"\"\"Load .env into os.environ; return path used.\"\"\"\n path = env_file()\n if path.is_file():\n load_dotenv(path, override=True)\n return path\n\n\ndef read_env_text() -> str:\n path = env_file()\n if path.is_file():\n return path.read_text(encoding=\"utf-8\")\n example = find_repo_root() / \".env.example\"\n if example.is_file():\n return example.read_text(encoding=\"utf-8\")\n return \"SN_BASE_URL=https://token.sensenova.cn/v1\\nSN_API_KEY=\\n\"\n\n\ndef write_env_text(content: str) -> Path:\n path = env_file()\n path.parent.mkdir(parents=True, exist_ok=True)\n path.write_text(content, encoding=\"utf-8\")\n reload_env()\n return path\n\n\ndef parse_env_dict(content: str) -> dict[str, str]:\n result: dict[str, str] = {}\n for line in content.splitlines():\n line = line.strip()\n if not line or line.startswith(\"#\"):\n continue\n if \"=\" not in line:\n continue\n key, _, val = line.partition(\"=\")\n result[key.strip()] = val.strip().strip('\"').strip(\"'\")\n return result\n\n\ndef validate_env(content: str | None = None) -> tuple[list[str], list[str]]:\n \"\"\"Return (errors, warnings).\"\"\"\n data = parse_env_dict(content) if content is not None else dict(dotenv_values(env_file()))\n errors: list[str] = []\n warnings: list[str] = []\n\n base = data.get(\"SN_BASE_URL\", \"\") or os.environ.get(\"SN_BASE_URL\", \"\")\n key = data.get(\"SN_API_KEY\", \"\") or os.environ.get(\"SN_API_KEY\", \"\")\n\n if not base:\n errors.append(\"缺少 SN_BASE_URL\")\n elif not base.startswith(\"http\"):\n errors.append(\"SN_BASE_URL 应以 http:// 或 https:// 开头\")\n\n if not key:\n errors.append(\"缺少 SN_API_KEY(可在 https://platform.sensenova.cn 申请)\")\n elif len(key) < 8:\n warnings.append(\"SN_API_KEY 似乎过短\")\n\n return errors, warnings\n\n\ndef mask_secret(value: str, visible: int = 4) -> str:\n if not value:\n return \"(未设置)\"\n if len(value) <= visible * 2:\n return \"***\"\n return f\"{value[:visible]}...{value[-visible:]}\"\n\n\ndef mask_api_key_status(value: str) -> str:\n \"\"\"Human-readable API key status for UI (never the full secret).\"\"\"\n if not value or not value.strip():\n return \"未配置\"\n v = value.strip()\n if v.startswith(\"sk-\"):\n if len(v) <= 8:\n return \"已配置 (sk-****)\"\n return f\"已配置 (sk-…{v[-4:]})\"\n tail = v[-4:] if len(v) >= 4 else \"****\"\n return f\"已配置 (…{tail})\"\n\n\nSECRET_ENV_KEYS = frozenset(\n {\n \"SN_API_KEY\",\n \"SN_IMAGE_GEN_API_KEY\",\n \"SN_VISION_API_KEY\",\n \"SN_CHAT_API_KEY\",\n }\n)\n\nFORM_ENV_KEYS = (\n \"SN_BASE_URL\",\n \"SN_API_KEY\",\n \"SN_IMAGE_GEN_API_KEY\",\n \"SN_VISION_API_KEY\",\n \"SN_CHAT_API_KEY\",\n \"SN_IMAGE_GEN_MODEL\",\n \"SN_SKILLS_ROOT\",\n)\n\n# Optional per-capability keys; blank means fall back to SN_API_KEY / SN_CHAT_API_KEY.\nOPTIONAL_FALLBACK_ENV_KEYS = frozenset({\n \"SN_IMAGE_GEN_API_KEY\",\n \"SN_VISION_API_KEY\",\n \"SN_CHAT_API_KEY\",\n})\n\n\ndef read_env_fields() -> dict[str, str]:\n \"\"\"Read known .env keys (values as stored on disk, not for UI display of secrets).\"\"\"\n path = env_file()\n if path.is_file():\n data = dict(dotenv_values(path))\n else:\n data = parse_env_dict(read_env_text())\n reload_env()\n out: dict[str, str] = {}\n for key in FORM_ENV_KEYS:\n val = (data.get(key) or os.environ.get(key) or \"\").strip()\n if val:\n out[key] = val\n return out\n\n\ndef apply_env_updates(updates: dict[str, str | None]) -> str:\n \"\"\"Merge updates into .env text. None for a key means keep the existing value.\"\"\"\n path = env_file()\n if path.is_file():\n lines = path.read_text(encoding=\"utf-8\").splitlines()\n else:\n lines = read_env_text().splitlines()\n\n seen: set[str] = set()\n new_lines: list[str] = []\n\n for line in lines:\n stripped = line.strip()\n if stripped and not stripped.startswith(\"#\") and \"=\" in stripped:\n key, _, _rest = line.partition(\"=\")\n key = key.strip()\n if key in updates:\n seen.add(key)\n val = updates[key]\n if val is None:\n new_lines.append(line)\n elif val == \"\" and key in OPTIONAL_FALLBACK_ENV_KEYS:\n continue # drop empty override so SN_API_KEY fallback applies\n else:\n new_lines.append(f\"{key}={val}\")\n continue\n if (\n stripped\n and not stripped.startswith(\"#\")\n and \"=\" in stripped\n and stripped.split(\"=\", 1)[0].strip() in OPTIONAL_FALLBACK_ENV_KEYS\n and not stripped.split(\"=\", 1)[1].strip()\n ):\n continue # strip legacy empty override lines on any save\n new_lines.append(line)\n\n for key in FORM_ENV_KEYS:\n if key in updates and key not in seen:\n val = updates[key]\n if val is None:\n continue\n if val == \"\" and key in OPTIONAL_FALLBACK_ENV_KEYS:\n continue\n new_lines.append(f\"{key}={val}\")\n\n return \"\\n\".join(new_lines) + (\"\\n\" if new_lines else \"\")\n\n\ndef config_summary() -> dict[str, Any]:\n reload_env()\n return {\n \"SN_BASE_URL\": os.environ.get(\"SN_BASE_URL\", \"\"),\n \"SN_API_KEY\": mask_secret(os.environ.get(\"SN_API_KEY\", \"\")),\n \"SN_IMAGE_GEN_MODEL\": os.environ.get(\"SN_IMAGE_GEN_MODEL\", \"(默认 sensenova-u1-fast)\"),\n \"SN_CHAT_MODEL\": os.environ.get(\"SN_CHAT_MODEL\", \"(默认)\"),\n \"repo_root\": str(find_repo_root()),\n \"skills_count\": len(__import__(\"sn_studio.core.paths\", fromlist=[\"list_sn_skills\"]).list_sn_skills()),\n }\n\n\ndef sanitize_log(text: str) -> str:\n \"\"\"Remove likely secrets from log snippets.\"\"\"\n patterns = [\n (r\"sk-[A-Za-z0-9_-]{8,}\", \"sk-***\"),\n (r\"(SN_API_KEY=)([^\\s]+)\", r\"\\1***\"),\n (r\"(api[_-]?key[\\\"']?\\s*[:=]\\s*[\\\"']?)([^\\\"'\\s]+)\", r\"\\1***\", re.I),\n ]\n out = text\n for item in patterns:\n if len(item) == 2:\n pat, repl = item\n flags = 0\n else:\n pat, repl, flags = item # type: ignore[misc]\n out = re.sub(pat, repl, out, flags=flags)\n return out\n"}}, "docs/promo/sensenova-skills-promo.md": {"f9bd94b067ccd5780e8b33f793d79e33ce512720": {"t": 1785768258.0336244, "n": 12322, "h": "90518c0900343300", "c": "# SenseNova Skills:为商汤日日新 U1 打造的办公技能包\n\n> 围绕 **`sensenova-u1-fast`(SenseNova U1)** 与配套 **sn-\\*** 技能;在 Agent 或 **SenseNova Skills Studio** 中本地调用([申请 API Key](https://platform.sensenova.cn/console/keys))。 \n> **仓库**:[OpenSenseNova/SenseNova-Skills](https://github.com/OpenSenseNova/SenseNova-Skills) · MIT 协议\n\n---\n\n过去两年,大模型能力的竞争焦点从「能不能答对一题」转向「能不能把一件事做完」。在真实办公场景里,用户很少只需要一段流畅的文字——他们需要:\n**SenseNova U1** 是商汤日日新体系里的**文生图模型**,在开放平台上的常用模型 ID 为 **`sensenova-u1-fast`**(快速版,适合批量出图与 Studio 交互场景)。通过 [SenseNova 开放平台](https://platform.sensenova.cn) 的 API(`https://token.sensenova.cn/v1`)即可调用,与对话、识图共用同一套 **`SN_API_KEY`**(在 [控制台 · Keys](https://platform.sensenova.cn/console/keys) 创建)。\n- 一份**能发给老板**的 PPT,版式统一、配图合理、页与页之间叙事连贯;\n- 一张**能进汇报材料**的 Excel 分析表,大文件不 OOM、结论有图表支撑;\n- 一篇**能经得起追问**的行业报告,来源可追溯、数字冲突要先 reconcile 再落稿;\n- 一张**能直接传播**的信息图,不是「随便画一张」,而是布局、风格、文字可读性都过关。\n\n这些任务有一个共同特征:**步骤长、约束多、中间产物必须落盘**。只靠通用 Chat 或单次 API 调用,智能体很容易在第三步「即兴发挥」:用 `pd.read_excel()` 硬读十万行、搜三条网页就写尽调、PPT 每页风格漂移。问题不在模型不够聪明,而在于**缺少可复用的执行契约**。\n\n## 一、SenseNova U1:生图模型与它能做什么\n\n**SenseNova U1** 是商汤日日新体系里的**文生图模型**,在开放平台上的常用模型 ID 为 **`sensenova-u1-fast`**(快速版,适合批量出图与 Studio 交互场景)。通过 [SenseNova 开放平台](https://platform.sensenova.cn) 的 API(`https://token.sensenova.cn/v1`)即可调用,与对话、识图共用同一套 **`SN_API_KEY`**(在 [控制台 · Keys](https://platform.sensenova.cn/console/keys) 创建)。\n\n**U1 适合产出什么?**\n\n| 场景 | 说明 |\n|------|------|\n| 营销 / 运营物料 | 海报、横幅、社交媒体配图,支持常见宽高比 |\n| 信息图 | 结构化版面 + 图中中文文案(需配合扩写与版式 skill) |\n| PPT 创意页 | 每页一整张 16:9 视觉稿(创意模式) |\n| 系列图 | 同一风格下的多张分镜(系列批量) |\n| 简历视觉稿、风格模仿图 | 固定版式或参考图风格 |\n\n单次调用 U1 API 只能得到「一张按 Prompt 生成的图」。真实办公里还需要:**Prompt 扩写、版式选型、多轮试稿、VLM 质检、文件落盘**——这些由本仓库的 **sn-\\*** 技能包在 U1 之上补齐;扩写、规划、识图等环节则使用 **`sensenova-6.7-flash-lite`(SenseNova 6.7)**,与 U1 分工明确:\n\n| 环节 | 默认模型 | 作用 |\n|------|----------|------|\n| **出图** | `sensenova-u1-fast` | 最终像素交付 |\n| **扩写 / 规划 / 成稿** | `sensenova-6.7-flash-lite` | Prompt、大纲、研究报告文字 |\n| **识图 / 质检** | `sensenova-6.7-flash-lite` | 图表理解、信息图与 PPT 分页评审 |\n\n---\n\n## 二、SenseNova Skills:U1 的配套技能包,以及怎么用\n\n### 2.1 这是什么\n\n**[SenseNova-Skills](https://github.com/OpenSenseNova/SenseNova-Skills)** 是一组开源 **Agent Skills**(遵循 [agentskills.io](https://agentskills.io/) 规范):每个技能一个目录 + `SKILL.md`,写清**何时用、怎么用、产物放哪**。它们通过底层 [`sn-image-base`](../../skills/sn-image-base/SKILL.md) 调用商汤 API——**生图走 U1,其余走 6.7**——把模型能力变成可重复执行的工序,而不是聊天里随口一说。\n\n### 2.2 技能清单(按场景)\n\n**图像与可视化(围绕 U1)**\n\n| 技能 | 作用 |\n|------|------|\n| `sn-image-base` | 底层封装:文生图、识图、文本优化(供其它图像 skill 调用) |\n| `sn-image-doctor` | 检查依赖、Key、模型是否可用 |\n| `sn-infographic` | 专业信息图:评估 Prompt → 选布局/风格 → 多轮 U1 生成 → VLM 选优 |\n| `sn-image-imitate` | 按参考图风格生成新图 |\n| `sn-image-resume` | 简历文字 → 简历视觉图 |\n\n**演示文稿(页内素材大量用 U1)**\n\n| 技能 | 作用 |\n|------|------|\n| `sn-ppt-entry` | 统一入口:收集需求,生成 `task_pack.json` |\n| `sn-ppt-standard` | 标准模式:HTML 分页 + VLM 评审 → 导出 PPTX |\n| `sn-ppt-creative` | 创意模式:每页一张 U1 全图 PNG |\n| `sn-ppt-doctor` | PPT 流水线环境检查 |\n\n**数据分析**\n\n| 技能 | 作用 |\n|------|------|\n| `sn-da-excel-workflow` | 多 Sheet Excel 清洗、统计、导出(编排器) |\n| `sn-da-large-file-analysis` | 十万行级大表流式处理 |\n| `sn-da-image-caption` | 从图表截图提取数据 |\n\n**深度研究 + 搜索 + 报告**\n\n| 技能 | 作用 |\n|------|------|\n| `sn-deep-research` | 总控:规划 → 分维度取证 → 综合 → 成稿 |\n| `sn-research-planning` / `sn-dimension-research` / `sn-research-synthesis` / `sn-research-report` | 研究各阶段 |\n| `sn-search-academic` / `sn-search-code` / `sn-search-social-cn` / `sn-search-social-en` | 学术、开发者、中英文社交搜索 |\n| `sn-report-format-discovery` / `sn-md-to-html-report` | 报告结构发现、MD 转离线 HTML |\n| `sn-update` | 在 Agent 环境中更新 sn-\\* 包 |\n\n一条完整业务链示例见仓库 `examples/memory-price-end2end-analysis`:Excel 分析 → 深度研究 → PPT 汇报,由多个入口 skill 通过磁盘上的文件交接完成。\n\n### 2.3 怎么使用这些技能\n\n**第一步:拿到 API Key**\n\n在 **[platform.sensenova.cn/console/keys](https://platform.sensenova.cn/console/keys)** 注册并创建 Key,写入仓库根目录 `.env`:\n\n```env\nSN_BASE_URL=https://token.sensenova.cn/v1\nSN_API_KEY=sk-你的密钥\n```\n\n**方式 A — 在智能体里用(适合长流程、可改 skill)**\n\n1. 将 `skills/` 下各目录安装到 Agent 的 skills 路径(如 OpenClaw:`~/.openclaw/skills/`,Cursor:`.cursor/skills/`)。 \n2. 重启 Agent,在对话中用自然语言触发,例如:「用 sn-image-doctor 检查环境」「按 sn-infographic 做一张行业信息图」「启动 sn-deep-research 写尽调」。 \n3. Agent 会按 `SKILL.md` 调用脚本、读写 `outputs/` 等目录中的产物。\n\n详细安装见 [`INSTALL_CN.md`](../../INSTALL_CN.md)。也可让 Agent 自行克隆仓库:*「请安装 https://github.com/OpenSenseNova/SenseNova-Skills」*。\n\n**方式 B — 不想自己配 Agent?**\n\n[小浣熊](https://office.xiaohuanxiong.com/home) 已集成同源 **U1 + 6.7** 能力与 Cowork-Skill,云端开箱即用。本文重点介绍的开源路径,则适合要**本地保管 Key、改 skill、接 Cursor/OpenClaw** 的团队。\n\n---\n\n**下一节**介绍我们为此开发的 **SenseNova Skills Studio**(`sn_studio`):在不装 Agent 的情况下,用浏览器完成配 Key、点按钮跑 skill、查看 U1 出图与 Excel/PPT 产物——同一套 sn-\\*,多一种**图形化**用法。\n\n---\n\n## 三、SenseNova Skills Studio:Skills 的本地适配工具\n\n### 3.1 它解决什么问题\n\n第二节里的技能包,默认要在 Cursor / OpenClaw 等 Agent 里通过对话触发。**Studio** 面向「想用 U1 和 sn-\\*,但不想先搭 Agent」的用户,定位很直接:\n\n> **不改动 `skills/` 里的 SKILL 逻辑,只在上层提供 Gradio 控制面板,把既有脚本变成可配置、可触发、可浏览产物的本地 Web 应用。**\n\n换句话说:技能包定义**工序与验收标准**;Studio 定义**人机界面与任务调度**——二者共用同一套商汤日日新 API 与 `.env` 配置。\n\n### 3.2 使用前:注册 Key,即可本地使用\n\n使用 Studio **不需要**先搭 Agent 运行时。只需:\n\n1. 打开 **[SenseNova 开放平台 · API Keys](https://platform.sensenova.cn/console/keys)** 注册并创建密钥(亦可了解 [Token 套餐](https://platform.sensenova.cn/token-plan))。 \n2. 克隆本仓库,在内层 `REPO_ROOT`(含 `pyproject.toml`)复制 `.env.example` 为 `.env`,填入:\n\n ```env\n SN_BASE_URL=https://token.sensenova.cn/v1\n SN_API_KEY=sk-你的密钥\n ```\n\n3. 安装并启动 Studio(Windows 可用一键脚本):\n\n ```powershell\n cd D:\\github\\SenseNova-Skills\\SenseNova-Skills\n powershell -ExecutionPolicy Bypass -File .\\scripts\\install_studio.ps1\n python -m sn_studio\n ```\n\n4. 浏览器打开 **http://127.0.0.1:7860** → **设置** Tab 保存并 **测试 API** → 即可在图像、PPT、数据分析等 Tab 调用全部已适配能力。\n\n密钥仅保存在本机 `.env`,界面脱敏显示;推理与生图请求直连 `token.sensenova.cn`,不经过第三方中转。\n\n![SenseNova 控制台 API Keys](screenshot-sensenova-keys.png)\n\n![Studio 设置页:保存 Key 与 API 测试通过](screenshot-studio-settings.png)\n\n### 3.3 Studio 是什么:本地 Web 控制台,不是又一个聊天框\n\nSenseNova Skills Studio 运行在你本机(默认 `http://127.0.0.1:7860`),基于 **Gradio 5** 搭建。它不做「万能对话」,而是把第二节列出的 sn-\\* 能力拆成**固定的 Tab + 表单 + 按钮**:\n\n- 该填什么的框都写好标签(Prompt、宽高比、扩写模式、Excel 路径……) \n- 该跑多久的任务交给后台子进程,界面用阶段条告诉你「正在扩写 / 正在用 U1 出图」 \n- 该落盘的结果进 `outputs/`,Gallery 里能预览,**本会话历史**里能一键回看 \n\n因此它特别适合:运营、设计、分析同事**不想学 Agent 话术**,但希望**稳定复用**同一套 U1 信息图、系列图、Excel 探查能力的人;也适合开发者**先在本机把 Key 和流水线跑通**,再接到 Cursor 里做长流程编排。\n\n![Studio 主界面:顶栏与各 Tab](screenshot-studio-home.png)\n\n### 3.4 七个 Tab:分别能帮你做什么\n\n| Tab | 你会用到的情况 | 背后调用的能力 |\n|-----|----------------|----------------|\n| **设置** | 第一次安装、换 Key、确认 U1/6.7 是否可达、跑环境诊断 | `.env` 管理、`sn-image-doctor` / `sn-ppt-doctor` |\n| **图像** | 出单张图、信息图、系列图、模仿参考图、简历视觉稿 | U1 + 图像流水线(见 §3.5) |\n| **PPT** | 从 brief 建 deck、按阶段生成 HTML/素材、调试某一页 | `sn-ppt-entry`、`sn-ppt-standard` / `sn-ppt-creative` |\n| **数据分析** | 上传 Excel 先看有多少 Sheet、多少行,再决定要不要上完整分析 skill | `sn-da-excel-workflow` 探查能力 |\n| **深度研究** | 建 `research/` 目录、写 `request.md`、把报告转成 HTML 预览 | `sn-deep-research` 工件约定 |\n| **搜索** | 快速查论文、GitHub、知乎/B 站等,结果以表格呈现 | `sn-search-*` 系列 |\n| **更新** | 从 Git 拉取最新 sn-\\* 技能包 | 仓库 `git pull` |\n\n复杂任务(例如深度研究**全自动**多轮取证、PPT **一口气**生成二十多页)仍建议在 Cursor / OpenClaw 里加载完整 `SKILL.md`;Studio 的定位是**把高频、可点击的能力做到顺手**,而不是替代整个 Agent。\n\n### 3.5 图像 Tab:最值得先体验的一块\n\n图像 Tab 内再分五个子页,共用**左右分栏**布局:左侧输入与参数,右侧预览与会话历史——和常见生图产品一致,降低学习成本。\n\n| 子页 | 你怎么用 | U1 何时介入 |\n|------|----------|-------------|\n| **文生图** | 写一句中文描述 → 选比例 → 生成 | 扩写完成后调用 U1 |\n| **信息图** | 贴业务摘要或要点 → 自动选型版式风格 → 多轮出图并质检 | 多轮 U1 + 6.7 VLM 选优 |\n| **系列批量** | **一句话主题** + 选择张数(3–8)→ 自动拆镜、统一风格、批量出图 | 全系列共享 seed,逐张 U1 |\n| **风格模仿** | 上传参考图 + 新内容说明 | 识图后 U1 按风格重画 |\n| **简历图** | 粘贴简历正文 | 结构化扩写后 U1 出视觉稿 |\n\n生成过程中,右侧**阶段条**会依次提示(例如:分析内容 → 扩写 Prompt → 生成图像);完成后可在 **「扩写后的 Prompt」** 折叠区查看实际发给 U1 的文案,便于复盘和二次修改。产物除显示在 Gallery 外,还会写入 `outputs/studio/<模块>/<时间戳>/`,本机可用 **「打开输出文件夹」** 直接定位文件。\n\n![文生图:左右分栏 + 完成态预览](screenshot-studio-image-generate.png)\n\n![系列批量:多图预览与会话历史](screenshot-studio-image-series.png)\n\n![信息图:成品预览](screenshot-studio-infographic.png)\n\n### 3.6 其它 Tab 怎么用(简例)\n\n- **PPT**:填写角色、受众、页数,上传 pdf/docx/md 附件 → 生成 `task_pack.json` → 按阶段执行 `run_stage`(例如只跑大纲或只跑某一页素材),在浏览器里看 HTML 预览,产物在 `ppt_decks/`。 \n- **数据分析**:指定 xlsx 路径 → 一键探查 Sheet 名与行数;大表会提示是否走 Parquet/流式策略,避免一上来内存爆掉。 \n- **深度研究**:创建课题目录、编辑 `request.md`;完整多维度调研仍在 Agent 里跑,Studio 负责**建架子、转 HTML 方便阅读**。 \n- **搜索**:选学术 / 代码 / 社交源,输入关键词 → 表格展示脚本返回的标题、链接、摘要,适合写报告前的快速摸底。\n\n![PPT Tab:deck 与阶段运行](screenshot-studio-ppt.png)\n\n![数据分析 Tab:Excel 探查结果](screenshot-studio-excel.png)\n\n![搜索 Tab:学术检索结果表](screenshot-studio-search.png)\n\n### 3.7 和 Agent 一起用时,Studio 扮演什么角色\n\n同一仓库、同一 `.env`、同一套 sn-\\*:\n\n| 场景 | 建议用 Studio | 建议用 Cursor / OpenClaw |\n|------|---------------|---------------------------|\n| 配 Key、测 API、出第一张 U1 图 | ✅ | 可选 |\n| 调试信息图 / 系列图参数 | ✅ | 可选 |\n| Excel 行数探查、单次搜索 | ✅ | ✅ |\n| 行业尽调全流程、PPT 全页循环 | 建目录、单步调试 | ✅ 主编排 |\n| 修改 `SKILL.md`、提 PR | — | ✅ |\n\nStudio 是**技能适配层上的图形壳**;Agent 是**对话编排层**。二者互补,不是二选一。\n\n![Cursor 中触发 sn-* 技能(可选对比图)](screenshot-cursor-agent.png)\n\n---\n\n## 四、Studio 如何集成 Skills(技术说明)\n\n### 4.1 集成架构:UI 不侵入 Skill 仓库\n\nStudio 采用**薄适配层**,保证技能包可独立演进、Studio 可单独升级:\n\n```text\n浏览器 (Gradio 5)\n ↓\nsn_studio/ui ← Tab、表单、Gallery、任务轮询\n ↓\nsn_studio/services/* ← 参数组装、调用 prompt 流水线\n ↓\nsn_studio/core/runner ← 子进程执行 skills/*/scripts 下既有入口\n ↓\nskills/<sn-*>/ ← SKILL.md 约定的脚本与产物(不修改)\n ↓\n商汤日日新 API ← U1 生图 / 6.7 推理·VLM(SN_API_KEY)\n```\n\n任务状态与输出路径写入 `outputs/.studio_jobs/jobs.json` 与 `outputs/studio/<模块>/`,便于会话内回看与重启后恢复历史(图像系列等目录规范见 `openspec/image-series-one-shot.md`)。\n\n### 4.2 图像流水线契约\n\n五个图像子页共用 `openspec/prompt-pipeline-unified.md` 定义的 **评估 → 扩写 → 生图** 阶段;信息图叠加 `sn-infographic` 的版式/风格选型,系列批量在扩写前增加 **6.7 拆镜** 与 **共享风格块 + 统一 seed**。界面上的阶段条与 `expanded-prompt.txt` 与 skill 脚本写入内容一致,便于对照调试。\n\n### 4.3 产物目录约定\n\n| 路径 | 内容 |\n|------|------|\n| `outputs/studio/generate/`、`infographic/`、`series/<时间戳>/` 等 | 图像模块输出 |\n| `outputs/.studio_jobs/jobs.json` | 任务状态(供本会话历史恢复) |\n| `ppt_decks/` | PPT `task_pack` 与分阶段产物 |\n| `research/<课题>/` | 深度研究工件 |\n\n---\n\n## 五、展望:Skills 适配层的下一步\n\n1. **更深的一体化预览** — 研究 / PPT / 系列图在 Studio 内直接预览 HTML、manifest 与多图网格,减少跳转资源管理器。 \n2. **可选「拆解预览」** — 系列批量、信息图在提交前展示 LLM 拆解的 N 条分镜,可编辑再生成。 \n3. **模板市场** — 将 `sn-infographic` 案例画廊、行业报告 `report_shape` 沉淀为 Studio 可选模板。 \n4. **团队配置** — 只读分享 `.env` 之外的「工序预设」(扩写模式、默认宽高比、品牌负向词)。 \n5. **与 Agent 运行时互认任务** — `jobs.json` 与 Agent 侧工件目录双向索引,Studio 点开即可续跑 Agent 未完成的任务。\n\n无论 Agent 生态如何演进,**Skills 作为「可交付工序」的抽象**不会过时;Studio 的角色,是把这套抽象持续翻译成**普通人也能点得动的本地工具**——而商汤日日新 API 始终是底下那块确定的算力底座。\n\n---\n\n## 六、结语\n\nSenseNova Skills 用 **sn-\\*** 技能包把商汤日日新模型浇铸成可审计的办公工序;**SenseNova Skills Studio** 则是这套工序面向本机的 **Skills 适配工具**——注册 [API Key](https://platform.sensenova.cn/console/keys),写入 `.env`,启动 `python -m sn_studio`,即可在浏览器里调用图像、PPT、Excel、研究与搜索能力,无需先学会在 IDE 里 `@skill`。\n\n若你已在用 Cursor 或 OpenClaw,Studio 是最佳的**配环境、试 API、看产物**伴侣;若你只想先把一张 U1 图、一张信息图或一份 Excel 探查跑通,Studio alone 就足够。技能包开源可改,适配工具持续迭代——欢迎 Star、Issue 与 PR。\n\n---\n\n## 附录 A:实机截图清单\n\n正文 §3 已预留 `![说明](文件名.png)` 占位,将截图放入 `docs/promo/` 同名文件即可显示。\n\n| 文件名 | 建议内容 |\n|--------|----------|\n| `screenshot-sensenova-keys.png` | [控制台 Keys](https://platform.sensenova.cn/console/keys)(密钥打码) |\n| `screenshot-studio-settings.png` | 设置页 API 测试通过 |\n| `screenshot-studio-home.png` | Studio 全部 Tab |\n| `screenshot-studio-image-generate.png` | 文生图完成态 |\n| `screenshot-studio-image-series.png` | 系列批量多图 |\n| `screenshot-studio-infographic.png` | 信息图成品 |\n| `screenshot-studio-ppt.png` | PPT 单阶段 |\n| `screenshot-studio-excel.png` | Excel 探查 |\n| `screenshot-studio-search.png` | 学术搜索表格 |\n| `screenshot-cursor-agent.png` | (可选)Cursor 触发 skill |\n\n## 附录 B:核心 sn-\\* 技能索引(24 个顶层 SKILL)\n\n| 技能名 | 一句话 |\n|--------|--------|\n| `sn-image-base` | 调用 SenseNova API:U1 文生图 / 6.7 VLM·LLM(Tier 0) |\n| `sn-image-doctor` | 图像环境诊断 |\n| `sn-infographic` | 专业信息图(布局×风格×多轮质检) |\n| `sn-image-imitate` | 参考图风格模仿 |\n| `sn-image-resume` | 简历视觉稿 |\n| `sn-ppt-entry` | PPT 统一入口与 task_pack |\n| `sn-ppt-doctor` | PPT 环境诊断 |\n| `sn-ppt-standard` | HTML 分页 + VLM + PPTX |\n| `sn-ppt-creative` | 每页整图创意模式 |\n| `sn-da-excel-workflow` | Excel 全流程编排 |\n| `sn-da-large-file-analysis` | 十万行级流式分析 |\n| `sn-da-image-caption` | 图表/截图数据提取 |\n| `sn-deep-research` | 深度研究总控 |\n| `sn-research-planning` | 产出 plan.json |\n| `sn-dimension-research` | 单维度取证 |\n| `sn-research-synthesis` | 综合判断 synthesis.md |\n| `sn-research-report` | 终稿 report.md |\n| `sn-report-format-discovery` | 报告形态发现 |\n| `sn-md-to-html-report` | Markdown → 离线 HTML |\n| `sn-search-academic` | 学术搜索聚合 |\n| `sn-search-code` | 开发者搜索聚合 |\n| `sn-search-social-cn` | 中文社交搜索 |\n| `sn-search-social-en` | 英文社交搜索 |\n| `sn-update` | 技能包更新 |\n\n---\n\n*文档版本:2026-05-18 · 以 Studio 技能适配工具为主线*\n"}}, "skills/sn-ppt-standard/lib/model_client.py": {"f9bd94b067ccd5780e8b33f793d79e33ce512720": {"t": 1785768272.4832377, "n": 12127, "h": "b42d896173f2203c", "c": "\"\"\"Lightweight, self-contained LLM/VLM client for ppt-* skills.\n\nReads env from .env (via python-dotenv) and hits two chat endpoints:\n\n llm(system, user) -> str # SN_TEXT_* / SN_CHAT_* /v1/chat/completions\n vlm(system, user, images) -> str # SN_VISION_* / SN_CHAT_* /v1/chat/completions\n\n**T2I is intentionally NOT here.** Image generation routes through\nsn-image-base/scripts/sn_agent_runner.py sn-image-generate. This module is LLM/VLM only.\n\nNo asyncio. No adapters. No configs.Configs. Just httpx.\n\"\"\"\nfrom __future__ import annotations\n\nimport base64\nimport json\nimport mimetypes\nimport os\nimport sys\nimport time\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Any\n\ntry:\n from dotenv import load_dotenv\nexcept ImportError:\n def load_dotenv(*a, **kw): return False # noqa\n\nimport httpx\n\n\n_DEFAULT_CHAT_BASE_URL = \"https://token.sensenova.cn/v1\"\n_DEFAULT_CHAT_MODEL = \"sensenova-6.7-flash-lite\"\n\n\n# ---------------------------------------------------------------------------\n# .env loading — search repo root / skills/.env / cwd, in that order\n# ---------------------------------------------------------------------------\n\ndef _find_and_load_env() -> list[Path]:\n here = Path(__file__).resolve()\n # skills/sn-ppt-standard/lib/model_client.py → parents[3] = repo root\n repo_root = here.parents[3]\n loaded: list[Path] = []\n for candidate in (repo_root / \".env\", repo_root / \"skills\" / \".env\", Path.cwd() / \".env\"):\n if candidate.exists():\n load_dotenv(candidate, override=False)\n loaded.append(candidate)\n return loaded\n\n\n_LOADED_ENV = _find_and_load_env()\n\n\ndef _env(name: str, *fallbacks: str, default: str = \"\") -> str:\n for key in (name, *fallbacks):\n val = os.environ.get(key, \"\").strip()\n if val:\n return val\n return default\n\n\n# ---------------------------------------------------------------------------\n# Config (read lazily each call — cheap + supports env mutation between calls)\n# ---------------------------------------------------------------------------\n\n\n@dataclass\nclass LLMConfig:\n api_key: str\n base_url: str\n model: str\n timeout: float = 120.0\n\n @classmethod\n def from_env(cls) -> \"LLMConfig\":\n return cls(\n api_key=_env(\"SN_TEXT_API_KEY\", \"SN_CHAT_API_KEY\", \"SN_API_KEY\"),\n base_url=_env(\"SN_TEXT_BASE_URL\", \"SN_CHAT_BASE_URL\", \"SN_BASE_URL\", default=_DEFAULT_CHAT_BASE_URL),\n model=_env(\"SN_TEXT_MODEL\", \"SN_CHAT_MODEL\", default=_DEFAULT_CHAT_MODEL),\n timeout=float(_env(\"SN_TEXT_TIMEOUT\", \"SN_CHAT_TIMEOUT\", default=\"120\")),\n )\n\n\n@dataclass\nclass VLMConfig:\n api_key: str\n base_url: str\n model: str\n timeout: float = 120.0\n\n @classmethod\n def from_env(cls) -> \"VLMConfig\":\n return cls(\n api_key=_env(\"SN_VISION_API_KEY\", \"SN_CHAT_API_KEY\", \"SN_API_KEY\"),\n base_url=_env(\"SN_VISION_BASE_URL\", \"SN_CHAT_BASE_URL\", \"SN_BASE_URL\", default=_DEFAULT_CHAT_BASE_URL),\n model=_env(\"SN_VISION_MODEL\", \"SN_CHAT_MODEL\", default=_DEFAULT_CHAT_MODEL),\n timeout=float(_env(\"SN_VISION_TIMEOUT\", \"SN_CHAT_TIMEOUT\", default=\"120\")),\n )\n\n\n# NOTE: T2I is intentionally NOT handled here. Image generation must go through\n# sn-image-base/scripts/sn_agent_runner.py sn-image-generate. This module is LLM/VLM only.\n\n\n# ---------------------------------------------------------------------------\n# Errors\n# ---------------------------------------------------------------------------\n\n\nclass ModelClientError(RuntimeError):\n pass\n\n\nclass MissingConfigError(ModelClientError):\n pass\n\n\n# ---------------------------------------------------------------------------\n# LLM / VLM (OpenAI-compatible chat/completions)\n# ---------------------------------------------------------------------------\n\n\ndef _require(value: str, name: str) -> str:\n if not value:\n raise MissingConfigError(f\"{name} is not set (check .env)\")\n return value\n\n\n_LLM_CONNECT_TIMEOUT_S = 10.0\n_LLM_WRITE_TIMEOUT_S = 30.0\n_RETRIABLE_STATUS_CODES = {502, 503, 504}\n\n\ndef _build_llm_timeout(timeout_s: float) -> httpx.Timeout:\n return httpx.Timeout(timeout_s, connect=_LLM_CONNECT_TIMEOUT_S, write=_LLM_WRITE_TIMEOUT_S)\n\n\ndef _is_transient_llm_error(exc: httpx.HTTPError) -> bool:\n if isinstance(exc, (httpx.ReadTimeout, httpx.ConnectTimeout, httpx.ConnectError)):\n return True\n if isinstance(exc, httpx.HTTPStatusError):\n return exc.response.status_code in _RETRIABLE_STATUS_CODES\n return False\n\n\ndef _coerce_message_content(msg: Any) -> str:\n \"\"\"Extract plain assistant text from OpenAI-compatible response shapes.\n\n Supports:\n - message.content as a plain string\n - message.content as blocks list, concatenating text blocks\n - fallback fields occasionally returned by providers\n \"\"\"\n if isinstance(msg, str):\n return msg\n if isinstance(msg, list):\n parts: list[str] = []\n for blk in msg:\n if isinstance(blk, str):\n parts.append(blk)\n continue\n if not isinstance(blk, dict):\n continue\n if blk.get(\"type\") == \"text\":\n text = blk.get(\"text\")\n if isinstance(text, str):\n parts.append(text)\n continue\n if isinstance(text, dict):\n value = text.get(\"value\")\n if isinstance(value, str):\n parts.append(value)\n continue\n for key in (\"text\", \"value\", \"content\"):\n value = blk.get(key)\n if isinstance(value, str):\n parts.append(value)\n break\n if isinstance(value, dict) and isinstance(value.get(\"value\"), str):\n parts.append(value[\"value\"])\n break\n return \"\".join(parts).strip()\n if isinstance(msg, dict):\n content = msg.get(\"content\")\n if isinstance(content, (str, list)):\n text = _coerce_message_content(content)\n if text:\n return text\n for key in (\"text\", \"output_text\", \"value\"):\n value = msg.get(key)\n if isinstance(value, str) and value.strip():\n return value\n if isinstance(value, list):\n text = _coerce_message_content(value)\n if text:\n return text\n return \"\"\n\n\ndef llm(system_prompt: str, user_prompt: str, *, model: str | None = None,\n timeout: float | None = None, retries: int = 0,\n request_name: str = \"llm\") -> str:\n \"\"\"Call the LLM chat endpoint. Returns the assistant message text.\"\"\"\n cfg = LLMConfig.from_env()\n _require(cfg.api_key, \"SN_TEXT_API_KEY / SN_CHAT_API_KEY\")\n _require(cfg.base_url, \"SN_TEXT_BASE_URL / SN_CHAT_BASE_URL\")\n timeout_s = float(cfg.timeout if timeout is None else timeout)\n max_attempts = max(1, int(retries) + 1)\n timeout_cfg = _build_llm_timeout(timeout_s)\n\n url = f\"{cfg.base_url.rstrip('/')}/chat/completions\"\n payload: dict[str, Any] = {\n \"model\": model or _require(cfg.model, \"SN_TEXT_MODEL / SN_CHAT_MODEL\"),\n \"messages\": [\n {\"role\": \"system\", \"content\": system_prompt},\n {\"role\": \"user\", \"content\": user_prompt},\n ],\n }\n headers = {\n \"Authorization\": f\"Bearer {cfg.api_key}\",\n \"Content-Type\": \"application/json\",\n }\n for attempt in range(1, max_attempts + 1):\n started_at = time.monotonic()\n try:\n resp = httpx.post(url, json=payload, headers=headers, timeout=timeout_cfg)\n resp.raise_for_status()\n except httpx.HTTPError as e:\n body = \"\"\n if isinstance(e, httpx.HTTPStatusError):\n body = e.response.text[:500]\n elapsed = time.monotonic() - started_at\n if _is_transient_llm_error(e) and attempt < max_attempts:\n sys.stderr.write(\n f\"[llm:{request_name}] transient {type(e).__name__} on attempt {attempt}/{max_attempts} after {elapsed:.1f}s, retrying\\n\"\n )\n continue\n raise ModelClientError(\n f\"LLM call failed [{request_name}] attempt {attempt}/{max_attempts}: {type(e).__name__}: {e} after {elapsed:.1f}s | body: {body}\"\n ) from e\n\n data = resp.json()\n try:\n message = data[\"choices\"][0][\"message\"]\n except (KeyError, IndexError, TypeError) as e:\n raise ModelClientError(\n f\"LLM response shape unexpected [{request_name}]: {json.dumps(data)[:500]}\"\n ) from e\n text = _coerce_message_content(message)\n if text:\n return text\n raise ModelClientError(\n f\"LLM response had no usable text [{request_name}]: {json.dumps(data)[:500]}\"\n )\n\n\ndef vlm(system_prompt: str, user_prompt: str, images: list[str | Path], *,\n model: str | None = None) -> str:\n \"\"\"Call the VLM chat endpoint with one or more image paths. Returns assistant text.\"\"\"\n cfg = VLMConfig.from_env()\n _require(cfg.api_key, \"SN_VISION_API_KEY / SN_CHAT_API_KEY\")\n _require(cfg.base_url, \"SN_VISION_BASE_URL / SN_CHAT_BASE_URL\")\n\n content: list[dict[str, Any]] = [{\"type\": \"text\", \"text\": user_prompt}]\n for img in images:\n p = Path(img)\n if not p.exists():\n raise MissingConfigError(f\"image not found: {p}\")\n mime = mimetypes.guess_type(str(p))[0] or \"image/png\"\n b64 = base64.b64encode(p.read_bytes()).decode(\"ascii\")\n content.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{mime};base64,{b64}\"},\n })\n\n url = f\"{cfg.base_url.rstrip('/')}/chat/completions\"\n payload = {\n \"model\": model or _require(cfg.model, \"SN_VISION_MODEL / SN_CHAT_MODEL\"),\n \"messages\": [\n {\"role\": \"system\", \"content\": system_prompt},\n {\"role\": \"user\", \"content\": content},\n ],\n }\n headers = {\n \"Authorization\": f\"Bearer {cfg.api_key}\",\n \"Content-Type\": \"application/json\",\n }\n try:\n resp = httpx.post(url, json=payload, headers=headers, timeout=cfg.timeout)\n resp.raise_for_status()\n except httpx.HTTPError as e:\n body = \"\"\n if isinstance(e, httpx.HTTPStatusError):\n body = e.response.text[:500]\n raise ModelClientError(f\"VLM call failed: {e} | body: {body}\") from e\n\n data = resp.json()\n try:\n msg = data[\"choices\"][0][\"message\"][\"content\"]\n if isinstance(msg, list):\n return \"\".join(blk.get(\"text\", \"\") for blk in msg if blk.get(\"type\") == \"text\")\n return msg\n except (KeyError, IndexError, TypeError) as e:\n raise ModelClientError(f\"VLM response shape unexpected: {json.dumps(data)[:500]}\") from e\n\n\n# ---------------------------------------------------------------------------\n# Debug / health\n# ---------------------------------------------------------------------------\n\n\ndef env_summary() -> dict[str, str]:\n llm_cfg = LLMConfig.from_env()\n vlm_cfg = VLMConfig.from_env()\n return {\n \"loaded_env_files\": \" \".join(str(p) for p in _LOADED_ENV) or \"(none)\",\n \"text.base_url\": llm_cfg.base_url or \"(unset)\",\n \"text.model\": llm_cfg.model or \"(unset)\",\n \"vision.base_url\": vlm_cfg.base_url or \"(unset)\",\n \"vision.model\": vlm_cfg.model or \"(unset)\",\n }\n\n\nif __name__ == \"__main__\":\n # Quick sanity: `python -m ppt_standard.lib.model_client health` or similar\n if len(sys.argv) > 1 and sys.argv[1] == \"health\":\n for k, v in env_summary().items():\n print(f\"{k:24s} {v}\")\n try:\n out = llm(\"You are a helpful assistant.\", \"Say 'ok' and nothing else.\")\n print(f\"LLM ping: {out[:60]!r}\")\n except Exception as e:\n print(f\"LLM ping failed: {e}\")\n else:\n print(\"usage: python model_client.py health\")\n"}}, "skills/sn-image-base/scripts/sn_image_base/generation/sensenova.py": {"f9bd94b067ccd5780e8b33f793d79e33ce512720": {"t": 1785809900.7634952, "n": 17266, "h": "99f80719ed2983b6", "c": "from __future__ import annotations\n\nimport os\nimport tempfile\nimport time\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, Any, Literal\n\nimport httpx\nfrom PIL import Image\nfrom typing_extensions import override\n\nfrom sn_image_base.configs import global_configs, is_valid_base_url\nfrom sn_image_base.exceptions import InvalidBaseUrlError, MissingApiKeyError\nfrom sn_image_base.generation.core import ensure_output_path\nfrom sn_image_base.generation.core.client_base import (\n DEFAULT_HTTP_REQUEST_TIMEOUT,\n DEFAULT_MAX_CONNECTIONS,\n T2IBaseClient,\n)\nfrom sn_image_base.utils.error_utils import U1HttpErrorBase\n\nif TYPE_CHECKING:\n from collections.abc import Sequence\n\nDEFAULT_RESOLUTION: Literal[\"1K\", \"2K\", \"4K\"] = \"2K\"\nDEFAULT_ASPECT_RATIO = \"16:9\"\nDEFAULT_POLL_INTERVAL = 5.0\nOUTPUT_DIR = Path(\"/tmp/openclaw-sn-image\")\n\n\nIMAGE_GEN_ENDPOINT = \"/images/generations\"\n\n\nclass SensenovaText2ImageClient(T2IBaseClient):\n \"\"\"Async client for Sensenova text-to-image API.\"\"\"\n\n def __init__(\n self,\n api_key: str,\n base_url: str | None = None,\n *,\n model: str | None = None,\n max_connections: int = DEFAULT_MAX_CONNECTIONS,\n timeout: float = DEFAULT_HTTP_REQUEST_TIMEOUT,\n ssl_verify: bool = True,\n **kwargs: Any,\n ) -> None:\n \"\"\"Initialize the SensenovaText2ImageClient.\n\n Args:\n api_key (str):\n API key for authentication.\n base_url (str | None, optional):\n API base URL. If None, reads from SN_IMAGE_GEN_BASE_URL env var.\n model (str | None, optional):\n Model name. If None, reads from SN_IMAGE_GEN_MODEL env var.\n max_connections (int, optional):\n Maximum number of connections. Defaults to 100.\n timeout (float, optional):\n Total timeout in seconds for HTTP requests.\n Defaults to DEFAULT_HTTP_REQUEST_TIMEOUT.\n ssl_verify (bool, optional):\n If True, enable TLS verification. Defaults to True.\n \"\"\"\n api_key = api_key or global_configs.SN_IMAGE_GEN_API_KEY\n if not api_key:\n raise MissingApiKeyError(\n \"API key is missing: {}\".format(\n global_configs.get_env_var_help(\"SN_IMAGE_GEN_API_KEY\")\n )\n )\n base_url = base_url or global_configs.SN_IMAGE_GEN_BASE_URL\n if not base_url:\n raise InvalidBaseUrlError(\n \"Base URL is missing: {}\".format(\n global_configs.get_env_var_help(\"SN_IMAGE_GEN_BASE_URL\")\n )\n )\n if not is_valid_base_url(base_url):\n raise InvalidBaseUrlError(\n f\"Base URL is not a valid base URL: {base_url}. \"\n f\"Try setting environment variable(s): {global_configs.get_env_var_help('SN_IMAGE_GEN_BASE_URL')}\"\n )\n super().__init__(\n api_key=api_key,\n base_url=base_url,\n model=model,\n max_connections=max_connections,\n timeout=timeout,\n ssl_verify=ssl_verify,\n **kwargs,\n )\n\n @override\n async def generate(\n self,\n prompt: str,\n negative_prompt: str = \"\",\n *,\n model: str | None = None,\n image_size: Literal[\"1K\", \"2K\", \"4K\"] = DEFAULT_RESOLUTION,\n aspect_ratio: str = DEFAULT_ASPECT_RATIO,\n output_path: Path | None = None,\n **kwargs: Any,\n ) -> dict:\n \"\"\"Generate an image from text prompt.\n\n Args:\n prompt (str):\n Text prompt for image generation.\n negative_prompt (str, optional):\n Negative prompt. Defaults to \"\".\n model (str | None, optional):\n Model name override. Defaults to None.\n image_size (str, optional):\n Image size preset (\"1K\", \"2K\", \"4K\"). Defaults to DEFAULT_RESOLUTION.\n aspect_ratio (str, optional):\n Aspect ratio (e.g. \"16:9\", \"1:1\"). Defaults to DEFAULT_ASPECT_RATIO.\n output_path (Path | None, optional):\n Output path for the generated image. Defaults to None.\n **kwargs:\n Additional arguments reserved for backend compatibility.\n\n Returns:\n dict:\n Dictionary with keys: status, output (path), message.\n \"\"\"\n model = model or self.model or global_configs.SN_IMAGE_GEN_MODEL\n # Normalize image_size to uppercase for NanoBanana API\n image_size = image_size.upper() # type: ignore[assignment]\n output_format = \"png\"\n size = self._resolve_size(image_size, aspect_ratio)\n payload = self.build_payload(\n prompt=prompt,\n model=model,\n size=size,\n aspect_ratio=aspect_ratio,\n output_format=output_format,\n )\n headers = self.headers\n api_url = self.get_api_url(model)\n if output_path is None:\n OUTPUT_DIR.mkdir(parents=True, exist_ok=True)\n timestamp = time.strftime(\"%Y%m%d_%H%M%S\")\n output_path = OUTPUT_DIR / f\"t2i_{timestamp}.png\"\n output_path = ensure_output_path(output_path)\n\n client = await self._get_client()\n\n try:\n create_response = await client.post(\n api_url,\n json=payload,\n headers=headers,\n )\n data = self.parse_response(create_response)\n except U1HttpErrorBase as exc:\n details = exc.detail or \"\"\n field_name = None\n if exc.code == 404:\n field_name = \"SN_IMAGE_GEN_BASE_URL\"\n elif exc.code == 401:\n field_name = \"SN_IMAGE_GEN_API_KEY\"\n # elif exc.code == 400:\n # warnings.warn(f\"Bad request: {exc.message}; body: {payload}\", stacklevel=2)\n if field_name is not None:\n field_hint = global_configs.get_annotated_field(field_name)\n if field_hint is not None:\n env_names = list(field_hint.env_names) if field_hint.env_names else []\n if env_names:\n if len(env_names) == 1:\n details += (\n f\"\\nIs the environment variable `{env_names[0]}` set correctly?\"\n )\n else:\n env_names_str = \", \".join([f\"`{n}`\" for n in env_names])\n details += f\"\\nIs any of the following environment variable(s) set correctly: {env_names_str}?\"\n return {\n \"status\": \"failed\",\n \"error\": f\"HTTP {exc.code}: {exc.message}\",\n \"message\": details,\n }\n try:\n images_urls: list[str] = data[\"images_urls\"]\n if not images_urls:\n return {\n \"status\": \"failed\",\n \"error\": \"No image generated from the model\",\n }\n url = images_urls[-1]\n suffix = f\".{output_format}\"\n save_path = output_path.with_suffix(suffix)\n saved_path = await download_image(url, save_path)\n return {\n \"status\": \"ok\",\n \"output\": str(saved_path),\n \"message\": \"Image generated successfully\",\n }\n except httpx.HTTPStatusError as exc:\n return {\n \"status\": \"failed\",\n \"error\": f\"HTTP {exc.response.status_code}\",\n \"message\": f\"http error: {exc.response.status_code} {exc.response.text}\",\n }\n except (httpx.HTTPError, OSError, ValueError) as exc:\n return {\n \"status\": \"failed\",\n \"error\": type(exc).__name__,\n \"message\": f\"request error: {exc}\",\n }\n\n @property\n @override\n def api_key(self) -> str:\n api_key = self._api_key or global_configs.SN_IMAGE_GEN_API_KEY\n if not api_key:\n raise ValueError(\n \"API key is missing: {}\".format(\n global_configs.get_env_var_help(\"SN_IMAGE_GEN_API_KEY\")\n )\n )\n return api_key\n\n @property\n @override\n def base_url(self) -> str:\n base_url = self._base_url or global_configs.SN_IMAGE_GEN_BASE_URL\n if not base_url:\n raise ValueError(\n \"Base URL is missing: {}\".format(\n global_configs.get_env_var_help(\"SN_IMAGE_GEN_BASE_URL\")\n )\n )\n if not is_valid_base_url(base_url):\n raise ValueError(\n f\"Base URL is not a valid base URL: {base_url}. \"\n f\"Try setting environment variable(s): {global_configs.get_env_var_help('SN_IMAGE_GEN_BASE_URL')}\"\n )\n return base_url\n\n @override\n def get_api_url(self, _model: str | None = None) -> str:\n base_url = self.base_url.rstrip(\"/\")\n path = IMAGE_GEN_ENDPOINT.lstrip(\"/\")\n api_url = f\"{base_url}/{path}\"\n return api_url\n\n @override\n def build_payload(\n self,\n prompt: str,\n model: str,\n *,\n size: str | None = None,\n modalities: Sequence[str] = (\"text\", \"image\"),\n output_format: Literal[\"png\"] = \"png\",\n response_format: Literal[\"url\"] = \"url\",\n **kwargs: Any,\n ) -> dict[str, Any]:\n \"\"\"Build the payload for the SenseNova image-generation endpoint.\n\n Args:\n prompt (str): The prompt to generate an image for.\n model (str): The model to use for generation.\n size (str | None): Pixel size string (for example, \"1920x1920\").\n modalities (Sequence[str]): Reserved for compatibility; currently not sent.\n output_format (Literal[\"png\"]): The output format of the image. Defaults to \"png\".\n response_format (Literal[\"url\"]): The response format of the image. Defaults to \"url\".\n **kwargs (Any, optional): Additional parameters to pass to the API.\n\n Example:\n {\n \"model\": \"sensenova-u1-fast\",\n \"prompt\": \"A cat wearing a hat\",\n \"size\": \"1024x1024\",\n \"response_format\": \"url\",\n \"output_format\": \"png\",\n }\n \"\"\"\n payload = {\n \"model\": model,\n \"prompt\": prompt,\n # \"modalities\": modalities,\n \"size\": size,\n # \"n\": 1,\n \"response_format\": response_format,\n \"output_format\": output_format,\n **kwargs,\n }\n return payload\n\n @property\n @override\n def headers(self) -> dict[str, str]:\n if not self.api_key:\n raise MissingApiKeyError(\n \"API key is missing: {}\".format(\n global_configs.get_env_var_help(\"SN_IMAGE_GEN_API_KEY\")\n )\n )\n return {\n \"Authorization\": f\"Bearer {self.api_key}\",\n \"Content-Type\": \"application/json\",\n }\n\n @classmethod\n def _resolve_size(\n cls,\n resolution: Literal[\"1K\", \"2K\"] | str | None = None,\n aspect_ratio: ASPECT_RATIO_LITERALS | str | None = None,\n ) -> str | None:\n \"\"\"Convert (resolution, aspect_ratio) to a pixel size string.\n\n If aspect_ratio is None, returns the resolution as-is (e.g. \"1K\").\n \"\"\"\n if not resolution and not aspect_ratio:\n return None\n resolution = resolution or \"2K\"\n aspect_ratio = aspect_ratio or \"1:1\"\n if resolution == \"1K\":\n buckets = BUCKETS_1K\n elif resolution == \"2K\":\n buckets = BUCKETS_2K\n else:\n raise ValueError(f\"Unsupported resolution: {resolution!r}. Must be '1K' or '2K'.\")\n try:\n ws, _, hs = aspect_ratio.strip().partition(\":\")\n width = int(ws)\n height = int(hs)\n ratio = width / height\n except Exception as e:\n raise ValueError(f\"Invalid aspect ratio: {aspect_ratio!r}\") from e\n if ratio > 16 / 9:\n raise ValueError(f\"Aspect ratio {aspect_ratio!r} is too wide. Maximum is 16:9\")\n if ratio < 9 / 21:\n raise ValueError(f\"Aspect ratio {aspect_ratio!r} is too high. Maximum is 9:21\")\n w, h = _find_nearest_aspect_ratio(ratio, buckets)\n return f\"{w}x{h}\"\n\n @override\n def parse_response(self, response: httpx.Response) -> dict:\n \"\"\"Parse the response from the SenseNova image-generation endpoint.\n\n Example response data:\n\n ```json\n {\n \"data\": [{\n \"url\": \"https://cdn.sensenova.dev/gen/...\"\n }]\n }\n ```\n\n Args:\n response: The HTTP response from the SenseNova image-generation endpoint.\n\n Returns:\n dict: Parsed data with key ``images_urls``.\n \"\"\"\n raw_data = super().parse_response(response)\n\n images_urls: list[str] = []\n for item in raw_data.get(\"data\", []):\n url = item.get(\"url\")\n if url:\n images_urls.append(url)\n return {\"images_urls\": images_urls}\n\n\nasync def download_image(\n url: str,\n save_path: Path,\n timeout: float = DEFAULT_HTTP_REQUEST_TIMEOUT,\n) -> Path:\n \"\"\"Download an image from a URL.\n\n Args:\n url: The URL of the image to download.\n timeout: The timeout for the request.\n\n Returns:\n Path: The path to the downloaded image file.\n \"\"\"\n save_path.parent.mkdir(parents=True, exist_ok=True)\n temp_path: Path | None = None\n bytes_written = 0\n expected_length: int | None = None\n try:\n with tempfile.NamedTemporaryFile(\n dir=save_path.parent,\n prefix=f\".{save_path.name}.\",\n suffix=\".tmp\",\n delete=False,\n ) as temp_file:\n temp_path = Path(temp_file.name)\n async with httpx.AsyncClient(timeout=timeout) as client:\n async with client.stream(\"GET\", url) as response:\n response.raise_for_status()\n content_length = response.headers.get(\"content-length\")\n if content_length is not None:\n expected_length = int(content_length)\n async for chunk in response.aiter_bytes():\n bytes_written += len(chunk)\n temp_file.write(chunk)\n temp_file.flush()\n os.fsync(temp_file.fileno())\n\n if expected_length is not None and bytes_written != expected_length:\n raise OSError(\n f\"Downloaded image is incomplete: got {bytes_written} bytes, \"\n f\"expected {expected_length} bytes\"\n )\n\n assert temp_path is not None\n _validate_image_file(temp_path)\n temp_path.replace(save_path)\n return save_path\n except Exception:\n if temp_path is not None:\n temp_path.unlink(missing_ok=True)\n raise\n\n\ndef _validate_image_file(image_path: Path) -> None:\n \"\"\"Verify that the downloaded image can be decoded completely.\"\"\"\n with Image.open(image_path) as image:\n image.verify()\n with Image.open(image_path) as image:\n image.load()\n\n\ndef mime_type_to_suffix(mime_type: str) -> str:\n \"\"\"Convert MIME type to file suffix.\n\n Args:\n mime_type: MIME type.\n\n Returns:\n str: File suffix.\n \"\"\"\n if mime_type == \"image/jpeg\":\n return \".jpg\"\n elif mime_type == \"image/png\":\n return \".png\"\n elif mime_type == \"image/webp\":\n return \".webp\"\n else:\n return \".png\"\n\n\nASPECT_RATIO_LITERALS = Literal[\n \"2:3\", \"3:2\", \"3:4\", \"4:3\", \"4:5\", \"5:4\", \"1:1\", \"16:9\", \"9:16\", \"9:21\"\n]\nBUCKETS_1K: dict[ASPECT_RATIO_LITERALS, tuple[int, int]] = {\n \"2:3\": (1088, 1632),\n \"3:2\": (1632, 1088),\n \"3:4\": (1152, 1536),\n \"4:3\": (1536, 1152),\n \"4:5\": (1184, 1472),\n \"5:4\": (1472, 1184),\n \"1:1\": (1344, 1344),\n \"16:9\": (1792, 992),\n \"9:16\": (992, 1792),\n \"9:21\": (864, 2048),\n}\nBUCKETS_2K: dict[ASPECT_RATIO_LITERALS, tuple[int, int]] = {\n \"2:3\": (1664, 2496),\n \"3:2\": (2496, 1664),\n \"3:4\": (1760, 2368),\n \"4:3\": (2368, 1760),\n \"4:5\": (1824, 2272),\n \"5:4\": (2272, 1824),\n \"1:1\": (2048, 2048),\n \"16:9\": (2752, 1536),\n \"9:16\": (1536, 2752),\n \"9:21\": (1344, 3136),\n}\n\n\ndef _find_nearest_aspect_ratio(\n ratio: float,\n buckets: dict[ASPECT_RATIO_LITERALS, tuple[int, int]],\n) -> tuple[int, int]:\n wh_pairs = sorted(\n buckets.values(),\n key=lambda wh: abs(wh[0] / wh[1] - ratio),\n )\n return wh_pairs[0]\n\n\nif __name__ == \"__main__\":\n import asyncio\n\n async def main_async():\n client = SensenovaText2ImageClient(\n api_key=global_configs.SN_IMAGE_GEN_API_KEY,\n base_url=global_configs.SN_IMAGE_GEN_BASE_URL,\n )\n\n result = await client.generate(\n prompt=\"A cat wearing a hat\",\n image_size=\"1K\",\n aspect_ratio=\"16:9\",\n )\n print(result)\n\n asyncio.run(main_async())\n"}}, "skills/sn-image-base/scripts/tests/test_configs_resolve.py": {"f9bd94b067ccd5780e8b33f793d79e33ce512720": {"t": 1785846976.9141674, "n": 2225, "h": "ed97ead3571a63a5", "c": "\"\"\"Tests for env var fallback resolution in sn_image_base.configs.\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport sys\nimport unittest\nfrom pathlib import Path\nfrom unittest import mock\n\nSCRIPTS_DIR = Path(__file__).resolve().parents[1]\nsys.path.insert(0, str(SCRIPTS_DIR))\n\nfrom sn_image_base.configs import Configs, Field # noqa: E402\n\n\nclass TestFieldResolve(unittest.TestCase):\n def test_empty_override_falls_back_to_shared_key(self) -> None:\n field = Field(\"SN_IMAGE_GEN_API_KEY\", \"SN_API_KEY\", required=True, secret=True)\n env = {\n \"SN_IMAGE_GEN_API_KEY\": \"\",\n \"SN_API_KEY\": \"sk-global\",\n }\n with mock.patch.dict(os.environ, env, clear=False):\n self.assertEqual(field.resolve(), \"sk-global\")\n\n def test_dedicated_override_wins_over_shared_key(self) -> None:\n field = Field(\"SN_IMAGE_GEN_API_KEY\", \"SN_API_KEY\", required=True, secret=True)\n env = {\n \"SN_IMAGE_GEN_API_KEY\": \"sk-image\",\n \"SN_API_KEY\": \"sk-global\",\n }\n with mock.patch.dict(os.environ, env, clear=False):\n self.assertEqual(field.resolve(), \"sk-image\")\n\n def test_whitespace_only_treated_as_unset(self) -> None:\n field = Field(\"SN_IMAGE_GEN_API_KEY\", \"SN_API_KEY\", required=True, secret=True)\n env = {\n \"SN_IMAGE_GEN_API_KEY\": \" \",\n \"SN_API_KEY\": \"sk-global\",\n }\n with mock.patch.dict(os.environ, env, clear=False):\n self.assertEqual(field.resolve(), \"sk-global\")\n\n\nclass TestConfigsValidation(unittest.TestCase):\n def test_validate_passes_with_global_key_only(self) -> None:\n env = {\n \"SN_API_KEY\": \"sk-global-key-value\",\n \"SN_IMAGE_GEN_API_KEY\": \"\",\n \"SN_BASE_URL\": \"https://token.sensenova.cn/v1\",\n }\n with mock.patch.dict(os.environ, env, clear=False):\n cfg = Configs()\n errors, _warnings = cfg.validate_configs()\n image_errors = [e for e in errors if e[0] == \"SN_IMAGE_GEN_API_KEY\"]\n self.assertEqual(image_errors, [])\n self.assertEqual(cfg.SN_IMAGE_GEN_API_KEY, \"sk-global-key-value\")\n\n\nif __name__ == \"__main__\":\n unittest.main()\n"}}}}