1 line
60 KiB
JSON
1 line
60 KiB
JSON
{"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\n\n\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\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\n\n\n\n\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\n\n\n\n\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\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 已预留 `` 占位,将截图放入 `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"}}}} |