Compare commits

...
Author SHA1 Message Date
leeferandmultica-agent ea3a1c6f06 fix(agent): retain daylight input focus border from sample
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 20:22:02 +08:00
leeferandmultica-agent e2b69aff16 fix(agent): preserve home title and media label visibility
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 20:18:37 +08:00
leeferandmultica-agent cb35d9819a fix(agent): align header, tool cards and real message presentation
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 20:10:45 +08:00
leeferandmultica-agent afbf50447a fix(agent): match studio controls, thumbnail ratio and sample digits
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 18:53:44 +08:00
leeferandmultica-agent ab48895b99 fix: regenerate SSE contract vocabulary (HEL-785)
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 17:44:09 +08:00
leeferandmultica-agent dca216e2c7 test: align agent comparison content with real task events (HEL-732)
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 16:09:33 +08:00
leeferandmultica-agent 37cb6ec209 fix: keep agent task input reachable at small viewports (HEL-732)
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 16:03:08 +08:00
leeferandmultica-agent 31e5906462 feat: persist agent deliverables and explicit project draft handoff (HEL-732)
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 12:55:57 +08:00
leeferandmultica-agent 8d7e58645c fix: align B3 small button radii and primary disabled states
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 11:00:53 +08:00
leeferandmultica-agent 930cca653f HEL-774: record supplemental check exit and support a focused cleanup recheck
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 09:43:39 +08:00
leeferandmultica-agent 15ff2a3cac HEL-774: keep project feedback clear of fixed save and confirm actions
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 09:41:42 +08:00
leeferandmultica-agent 3dfed6780c HEL-774: wait for navigation, pending saves and theme transitions in focused checks
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 09:35:41 +08:00
leeferandmultica-agent f2ca010fcd HEL-774: restore studio and project secondary button sample tokens
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 09:32:52 +08:00
leeferandmultica-agent 30851d57f1 HEL-731: align studio and five-stage project with approved sample
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 08:34:59 +08:00
leeferandmultica-agent a1c01f1e90 docs(HEL-771): record raw pixel results and verification boundary
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 07:45:14 +08:00
leeferandmultica-agent fb6139d45c test(HEL-771): wait for sample border transition before capture
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 07:43:33 +08:00
leeferandmultica-agent af0417e874 fix(HEL-771): align theme tile hover tone and badge spacing
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 07:42:46 +08:00
leeferandmultica-agent b2bcbc10df fix(HEL-769): restore theme tile preview details from sample
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 07:07:44 +08:00
leeferandmultica-agent 2004f527cc fix(HEL-767): align management header, theme cards and shared rail with sample
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 06:38:32 +08:00
leeferandmultica-agent dca88b3765 fix(HEL-751): restore confirmed full-width task table
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 05:18:07 +08:00
leeferandmultica-agent 54880e900b fix: restore library entrance and document video caption exception
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 04:44:33 +08:00
leeferandmultica-agent 4c849e9ef9 docs: explain 29 scoped lint exceptions for HEL-762 batch 4
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 04:14:43 +08:00
leeferandmultica-agent 46fd5bbc86 fix: use Svelte reactive collections at isolated boundaries (HEL-760)
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 03:51:16 +08:00
leeferandmultica-agent fc6616b118 fix: preserve resource link semantics with external rel (HEL-758)
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 03:32:35 +08:00
leeferandmultica-agent 22f82fbed6 docs: record HEL-756 batch 1 implementation and validation
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 03:07:08 +08:00
leeferandmultica-agent 67eb822efe fix(navigation): keep tool destinations in a module constant
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 03:02:47 +08:00
leeferandmultica-agent 99574c2226 fix(state): preserve readonly runes and remove inert export flag
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:57:22 +08:00
leeferandmultica-agent 88965fa70d fix(lists): key 48 loops with verified identity or positional semantics
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:57:22 +08:00
leeferandmultica-agent 9d54b9d8dd fix(navigation): resolve the 74 approved application destinations
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:57:12 +08:00
leeferandmultica-agent a666f6bd12 docs: record HEL-753 joint type validation
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:39:47 +08:00
leeferandmultica-agent b02c50fdf7 fix(shot): type mirror and posture boundaries without assertions
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:39:47 +08:00
leeferandmultica-agent 7e9a5be19d fix(shot): declare real mannequin joints and numeric accessors
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:39:47 +08:00
leeferandmultica-agent e49b8b0072 docs(HEL-752): record validation and unresolved behavioral gates
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:25:13 +08:00
leeferandmultica-agent ddc02fbe4c chore(HEL-752): retain unchanged SCSS package registry entry
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:21:00 +08:00
leeferandmultica-agent d076978a4a refactor(HEL-752): remove unused breakpoint bookkeeping
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:20:23 +08:00
leeferandmultica-agent 4e200cc8ac refactor(HEL-752): type menu and blend contracts and document local exceptions
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:18:32 +08:00
leeferandmultica-agent bfcf542e59 refactor(HEL-752): remove unused locals while preserving evaluation and props
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:16:48 +08:00
leeferandmultica-agent 3d1c4ca5af fix(HEL-752): use Svelte-aware const checks and preserve legacy rules
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:15:16 +08:00
leeferandmultica-agent 24129438e8 chore(HEL-750): configure ESLint 9 and document remaining lint blockers
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:02:58 +08:00
leeferandmultica-agent 155d914df3 style(HEL-750): apply existing Prettier formatting to 61 files
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 01:57:34 +08:00
leeferandmultica-agent 64c1116bca fix(HEL-730): preserve shared pins and sample icon geometry
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 01:51:42 +08:00
leeferandmultica-agent 03bebec94a fix(HEL-730): align media header and connection add card
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 01:26:21 +08:00
leeferandmultica-agent 0f4d6a8961 fix(HEL-730): use approved standard button tokens
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 01:14:59 +08:00
leeferandmultica-agent 06c9bd5000 fix(HEL-730): match compact workflow filter sizing
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 01:12:34 +08:00
leeferandmultica-agent acb6beca1c fix(HEL-730): align measured control sizes and typography
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 01:05:33 +08:00
leeferandmultica-agent a6b04e5c91 feat(HEL-730): reshape management pages and preserve workflow tools
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 01:01:58 +08:00
leeferandmultica-agent 634965f5f5 HEL-743: restore search shortcut and full-width responsive desk header
Co-authored-by: multica-agent <github@multica.ai>
2026-10-21 23:26:41 +08:00
leeferandmultica-agent e62499c517 HEL-741: match runnable toggle to sample and record final regression
Co-authored-by: multica-agent <github@multica.ai>
2026-10-21 22:47:22 +08:00
leeferandmultica-agent b6b3a9fed3 HEL-741: restore workflow filters, header and result actions to sample
Co-authored-by: multica-agent <github@multica.ai>
2026-10-21 22:44:37 +08:00
leeferandmultica-agent 7bf63d0587 HEL-740: document existing AGit review update
Co-authored-by: multica-agent <github@multica.ai>
2026-10-21 21:56:46 +08:00
leeferandmultica-agent 5cb75d5718 HEL-740: restore approved creative desk dock and anchored controls
Co-authored-by: multica-agent <github@multica.ai>
2026-10-21 21:54:09 +08:00
leeferandmultica-agent 2304ab1538 feat(HEL-729): unify the Nocturne creative desk and isolate history
Co-authored-by: multica-agent <github@multica.ai>
2026-10-21 21:07:19 +08:00
leeferandmultica-agent 8e84e4c21f feat(HEL-728): establish approved themes and compatible studio navigation
Co-authored-by: multica-agent <github@multica.ai>
2026-10-21 20:13:03 +08:00
leeferandmultica-agent 35fd07af24 HEL-665: implement v5 creation hierarchy and project-bound agent drafts
Co-authored-by: multica-agent <github@multica.ai>
2026-10-15 00:33:26 +08:00
leeferandmultica-agent 012a946f85 fix(prototype): align scene aspect label with sample media
Co-authored-by: multica-agent <github@multica.ai>
2026-10-14 23:45:34 +08:00
leeferandmultica-agent 875dfcb41a design(HEL-665): rebuild creation hierarchy and interactive desktop prototype
Co-authored-by: multica-agent <github@multica.ai>
2026-10-14 23:42:32 +08:00
leeferandmultica-agent 780ac52ffc fix(agents): preserve copy fallback and guard new session submission
Co-authored-by: multica-agent <github@multica.ai>
2026-10-14 22:15:48 +08:00
leeferandmultica-agent d4e199f3c8 HEL-665: implement approved reference creative and agent interfaces
Co-authored-by: multica-agent <github@multica.ai>
2026-10-14 22:03:32 +08:00
leeferandmultica-agent ae9e34f6ab HEL-665: prioritize media inputs and keep short-window result actions visible
Co-authored-by: multica-agent <github@multica.ai>
2026-10-14 00:20:48 +08:00
leeferandmultica-agent 16933b1dfc HEL-665: switch results without overlapping video players
Co-authored-by: multica-agent <github@multica.ai>
2026-10-14 00:14:48 +08:00
leeferandmultica-agent 07f6475b1a HEL-665: rebuild ComfyUI creative journey with visible controls and isolated drafts
Co-authored-by: multica-agent <github@multica.ai>
2026-10-14 00:04:19 +08:00
leeferandmultica-agent ac1c0f98a6 HEL-665: preserve creative inputs and isolate cloud catalogue keyboard selection
Co-authored-by: multica-agent <github@multica.ai>
2026-10-12 00:54:27 +08:00
leeferandmultica-agent aaddd3dcaf HEL-665: implement approved Refract Studio across real desktop workbench
Co-authored-by: multica-agent <github@multica.ai>
2026-10-12 00:43:14 +08:00
总工 2b3bbf9d03 HEL-691: deploy S6 visual sweep (feat/hel-685-visual-sweep @ bc24370) 2026-10-11 18:41:38 +08:00
施工员andmultica-agent bc24370233 feat(style): 全站无彩灰阶收尾——清理 legacy 别名与硬编码色漂移 (HEL-685 S6)
- app.css 删除 8 个无引用 legacy 别名,新增 --id-1..5 身份灰阶实色阶
- 画布去紫罗兰二级 palette(tokens.css 删除,改用全站 token)
- 画布/镜头台/智能体/omni/视频组件硬编码色全部归位 token
- agentPalette 彩色身份色改为显影灰阶 --id 阶;修复 3 处相纸白底白字不可读
- WorkflowLibrary 试运行抽屉焦点还原(异步禁用触发键丢焦)修复
- e2e: fpages/import 色值断言对齐现行 token;新增 hel685 走查脚本
  (1440/1024/390 截图 + 控制台错误 + 减动效 + 键盘焦点 + 对比度探针);
  pagination 空库自举;fpages 增加逐页控制台错误检查

Co-authored-by: multica-agent <github@multica.ai>
2026-10-11 17:26:12 +08:00
总工 2f267a751f HEL-690: deploy S5 project flowback (feat/hel-684-project-attach @ 0841f5a) 2026-10-11 15:34:07 +08:00
施工员andmultica-agent 0841f5a296 feat(desk): 生成结果回流原项目——可选上下文透传 + 结果卡回流动作 (HEL-684 S5)
Co-authored-by: multica-agent <github@multica.ai>
2026-10-11 15:05:57 +08:00
总工 35aad34c7c HEL-689: deploy S4 three-band desk console (feat/hel-683-desk-console @ bef28fa) 2026-10-11 13:52:49 +08:00
施工员andmultica-agent bef28facee feat(desk): 三段式图片/视频生成台——任务模式栏+工作流挑选台+摘要带 (HEL-683 S4)
- 新增 components/desk/:StudioDesk 取代 StudioGenerate 成为 /image、/video
  共用页面;TaskModeRail(真实计数、未覆盖置灰、收藏/最近)、WorkflowBay
  (搜索、本机/云端分段、模型家族分组、待确认入口、增量渲染)、
  WorkflowPreview(输入要求预览)、LibraryRow/LibraryFilters/
  InputRequirementBadge
- 挑选时中栏 grid 列宽展开、右侧显示预览;选定后收成摘要带;
  data-reduce 直接跳变;≤1179px 挑选台为全屏浮层,≤760px 任务模式横条
- 挑选侧全部走 view=meta 轻量元数据,完整记录按 id 惰性加载;
  云端来源诚实标注未接入;?wf/?mode 深链;五阶段上下文与返回路径不变
- Composer 工作流芯片改为展开挑选台,删除 WorkflowPicker;
  i18n 29 键 ×7 语言;changelog
- e2e/shoot.mjs 适配挑选台键盘链,并修正 7 处 S1 灰阶化后失效的
  旧色值/文案断言(定影 + theme-1 灰阶信号色)

Co-authored-by: multica-agent <github@multica.ai>
2026-10-11 13:19:30 +08:00
总工 316fba97e1 HEL-688: deploy S4 workflow import wizard & meta admin (feat/hel-682-workflow-import-admin @ 1c00587) 2026-10-11 12:35:44 +08:00
施工员andmultica-agent 1c00587a56 feat(workflows): 工作流批量导入向导与元数据管理列表 (HEL-682 S4)
- WorkflowBatchImportWizard:目录扫描/多文件 → dry-run 逐文件报告 → 确认导入;
  warn 可补用途、需确认与 fail 项不导入不标可运行、重复导入全量 conflict 跳过
- WorkflowLibrary:view=meta 轻量列表(搜索/分类/模型/媒介/收藏/最近/启停/
  去重/批量标注),完整 JSON 仅操作时按需加载;试运行复用 WorkflowTestDialog;
  保留用途确认、输出指定、单文件导入等既有能力
- api/types:listMeta、convert、importBatch、importScanDry/Commit、touch、
  dedupe 客户端与 WorkflowMeta/转换报告类型;update 支持 favorite/tags/
  category/model_family
- i18n 130 键 ×7 语言;changelog

Co-authored-by: multica-agent <github@multica.ai>
2026-10-11 12:13:52 +08:00
总工 7c49250267 HEL-687: merge S2 workflow data layer (agent/agent/c85587840139) 2026-10-11 11:32:05 +08:00
leeferandmultica-agent c5aa575ac3 feat(workflows): UI→API 转换器 + 批量导入 + 元数据轻量列表 (HEL-681 S2)
- 新增 comfyui/ui_convert.py:离线确定性转换(reroute/bypass/mute 透传、
  子图展开为 packed:inner 限定 id、Set/Get 变量解析、控件三种映射来源,
  无法可靠映射的文件进入需人工确认,绝不静默猜测)
- 新增 convert / import-batch / import-scan 端点:dry-run 逐文件
  ok/warn/fail 报告 + 建议元数据;导入端冲突跳过、重复执行幂等
- workflows 表加列 category/model_family/media_io/tags/favorite/
  last_used_at/source/source_path(纯加列迁移,旧行为零影响)
- GET /api/workflows?view=meta 轻量列表不含全量 JSON;旧接口行为不变
- 元数据建议:分类/模型家族(主加载器加权+目录回退,规避 qwen_image_vae
  误标)/输入输出媒介/MarkdownNote 描述素材
- enqueue 时记录 last_used_at;PATCH 支持 favorite/tags 等字段
- 测试 23 项新增(转换语义单测+146 文件语料+对拍+端点/迁移/幂等)

Co-authored-by: multica-agent <github@multica.ai>
2026-10-11 11:20:23 +08:00
总工 6623ffa04c HEL-680: merge S1 grayscale visual foundation (feat/hel-680-grayscale-foundation) 2026-10-11 11:17:30 +08:00
施工员andmultica-agent 0ee8280ddb feat(theme): HEL-680 S1 全站无彩灰阶视觉底座——theme-1 灰阶 token 换值、取景框角线选中、相纸白边与「定影」完成态
Co-authored-by: multica-agent <github@multica.ai>
2026-10-11 11:01:21 +08:00
leefer 1fc8bdb8bb Add 146 ComfyUI workflow JSONs organized by category (0-基础处理/1-语音/2-音乐/3-图像/4-视频/5-数字人/6-动作迁移) 2026-10-10 20:30:14 +08:00
总工 2c3c05277b HEL-672: merge H3 first-last frame (feat/hel-671-first-last-frame) 2026-10-04 13:53:26 +08:00
施工员andmultica-agent 2689f5354a feat(comfy): H3 图生视频支持首尾帧——可选尾帧插槽、接线克隆 LoadImage 与改名迁移 (HEL-671)
Co-authored-by: multica-agent <github@multica.ai>
2026-10-04 13:40:11 +08:00
总工 7bd8c29faa HEL-670: merge last-frame export (feat/hel-669-last-frame) 2026-10-01 23:19:08 +08:00
施工员andmultica-agent 4f3293fc02 feat(media): 视频一键导出尾帧 PNG(生成结果页 + 共用播放弹层)(HEL-669)
- lib/media/exportFrame.ts:隐藏 video 取片尾前约 1 帧,黑帧/空帧逐级回退(≤1s),
  原生尺寸 canvas 导出 PNG;加载/损坏/404/403/跨域/超时/无法定位结尾分类报错
- lib/media/lastFrame.ts:下载 + 中文 Toast 反馈
- Inspector.svelte 操作区、ImageLightbox.svelte 视频工具栏加「导出尾帧」
- ToastHost 层级提到灯箱之上,弹层内反馈可见
- 七语言文案;npm test 新增 check-export-frame;e2e/lastframe.mjs 浏览器验收

Co-authored-by: multica-agent <github@multica.ai>
2026-10-01 23:12:34 +08:00
总工 4c87c6dea0 HEL-664: merge F-batch remaining pages & site-wide polish (feat/hel-656-f-pages) 2026-09-30 17:08:05 +08:00
总工andmultica-agent d882fd62d8 feat(ui): F批——其余页面融合收口与整站规范统一 (HEL-656)
- 移除 Calliope 旧壳死代码:/project/[id] 旧四段编辑器页面与 9 个遗留组件
  (NavRail/StoryStage/AssetsStage/ScriptStage/QueueStage/ActivityPanel/
  JobRow/AssetThumb/ProjectCard),/projects 死页面(308→/ 已有)
- 素材库 /library 迁入 AppShell 新壳:a-chip 筛选、ui/Modal、ConfirmDialog
  实心红两级删除、窄屏单列;移除 A 时期 308→/ 重定向;导航轨/更多菜单新增入口
- 智能体子系统去杂色:agentPalette 默认紫→中性、回退色板收敛为 avatar 五色族;
  AgentChat 气泡按 §3.2/3.6(用户 bg-3/Agent bg-2+line)、进行中指示改青、
  紫色空态图标清除;AgentComposer 天蓝/金芯片收敛
- 首页/工作台/五面板/StageStepper/PromptBlock/JobChip 类名收敛到全局 a-btn/
  a-input/a-tag/field;Stepper 补金色当前下划+已确认✓;镜头序号按规范用
  accent-lo+mono;分页/工作台选择页金色越界改中性或青
- 金色纪律修正:次级文字操作(重试/测试/链接)与必填星标、任务「测试」标签
  去金色;「默认」金标签按 §3.9 保留
- 断点收敛:1023/1279/900/780/700/640 → 1180/760(1440 增强保留)
- canvas/build-scene 可用级 ≤760 纵向堆叠适配 + 硬编码色/旧 fallback 清理;
  AppHeader 去 Calliope 品牌与死链
- favicon 收口:static/favicon.png 金色品牌图标,移除 204 stub 与构建豁免
- i18n 七语种 +3 key(shell.navLibrary/library.loadError/library.subtitle)全对齐
- 新增 e2e/fpages.mjs:F 页面三档走查/重定向/五段导入编辑/规范色断言 38 项

Co-authored-by: multica-agent <github@multica.ai>
2026-09-30 16:29:47 +08:00
总工 b4c7334f45 HEL-663: merge E-batch import & test-run drawers (feat/hel-655-e-drawers) 2026-09-30 15:17:19 +08:00
总工andmultica-agent 6064bd805d feat(ui): E批——工作流导入与试运行右侧抽屉 + 四步检查 (HEL-655)
- 新增 ui/Drawer.svelte 右侧抽屉基元:380/420px、--d-drawer 280ms
  --ease-drawer 可反向、Esc/Tab 焦点圈、遮罩点击关闭、减动效仅留 ≤120ms 淡变
- 新增 WorkflowImportDrawer.svelte:导入两步向导抽屉化,四步检查
  (解析 JSON→识别节点→核对模型→建立输入槽)逐条出现(80ms 错峰)、
  ✓绿/…青/✕红/待确认琥珀 + 中文原因 + 下一步建议 + 可展开错误 + 重新检查 +
  「查看原始 JSON」折叠;沿用 /api/workflows/analyze 原判定与保存边界
- WorkflowTestDialog.svelte 居中弹窗改右侧抽屉:运行中青芯片 + 进度条 +
  mono 阶段日志滚动区,结果缩略图横排、失败红 + 长错误可展开;
  逻辑(确认弹层、dry_run 拒绝、产物隔离、重复提交复用)不变
- connections 页:导入入口统一为抽屉按钮,次级文字操作(测试/重新检查/更改)
  改 ghost(HEL-649 v2 §1.2),连接卡片改 §3.7 网格;移除内联导入块与死样式
- i18n 七语种各 +16 键(wfd.*)计数一致
- e2e:fake_comfy.py 支持 object_info/no_outputs 场景;新增 import.mjs
  43 项浏览器回归全过;D 批 shoot.mjs 54/54 复跑通过

Co-authored-by: multica-agent <github@multica.ai>
2026-09-30 14:42:56 +08:00
总工 c3deaf90bb HEL-662: merge D-batch record stream (feat/hel-654-d-records) 2026-09-30 13:27:05 +08:00
总工andmultica-agent 949cd2ecad HEL-654: D 批——记录流状态语义、金框结果切换、撤销删除与任务页重皮
- 点选金框滑动 + 检视区 150ms 交叉淡变;↑↓/J/K 即时切换、空格播放、
  Delete 删除、] 检视区、浮层让位;新记录入场/完成金光/失败抖动,减动效只余短淡变
- 单条删除 5 秒撤销(Toast 金色动作钮),窗口过后才提交 DELETE,离页 flush;
  任务页同队列同步;工作流删除保留旧记录并标识「工作流已删除」
- 红/琥珀界线:失败红 + 中文结论展开;done 但文件取不到 → 琥珀「文件缺失」
  + 用相同参数重做/移除记录
- 参数页提交自动切结果页;工作流选择器 ↑↓ 真高亮 + Enter 使用(ShellPop arrowRoving 开关)
- 记录流 80 条分页(220 条专项:50 次轮询 490ms);任务页 §3.5 芯片+搜索+
  行内进度条+ghost 重试;i18n 七语种 +17 键

Co-authored-by: multica-agent <github@multica.ai>
2026-09-30 13:02:51 +08:00
总工 0c620e61fe HEL-661: merge C-batch generate desk (feat/hel-653-c-generate) 2026-09-30 11:39:13 +08:00
6f4d50f4f0 HEL-653: C 批生成台——拆分输入台/工作流选择/输入槽/记录流/检视区
- StudioGenerate 拆为 generate/ 下的 Composer、WorkflowPicker、SlotTray、ParamChip、
  TakeFeed/TakeCard、Inspector、ParamsPanel、ContextBar,逻辑收进 schema/takes/context
- 输入槽只按工作流 JSON 实际接线渲染;常用参数芯片 + 高级参数折叠,显示原值与已改标记
- 工作台素材/镜头卡新增「在生成台调参」,带项目上下文进入;结果不回填(后端无目标)
- 共享样式(a-chip/a-seg/a-slot/a-tile 等)进 app.css;ShellPop 支持向上弹出
- /image、/video 仍是同一生成台,按用途预选默认工作流与筛选

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-30 10:59:46 +08:00
8b03291327 HEL-652: B 批导航壳层——60px 图标轨、顶栏与手机底栏
- 216px 文字侧栏改为 60px 图标轨:金色渐变 Logo、当前页金色指示条滑动、350ms 延迟悬停提示,底部连接/模型/设置/语言
- 顶栏:标题+副标题、项目范围胶囊(全部项目列表/最近项目)、Ctrl/⌘K 搜索、减少动态开关、任务托盘胶囊、本次连接胶囊(名称+延迟)
- ≤760px:隐藏图标轨,底部 项目/创作/生成/更多 四个标签,更多为底部抽屉(其余页面、搜索、减少动态、语言);删除「手机端暂不适配」拦截
- 新增 ShellPop(Esc/↑↓/Tab 循环/外部点击关闭、焦点归还)与 CommandPalette(复用 Modal)
- 减少动态:跟随系统设置,另可手动开启并持久化(data-reduce)
- .a-darkroom 背景接入 --page-glow;新增壳层尺寸 token 与 .a-kbd/.a-menu-* 公共类
- 生图/生视频顶栏去掉与全局胶囊重复的连接状态(参数栏仍显示所选连接与队列)
- 路由 URL 不变(/projects 仍重定向到项目总览 /)

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 09:26:36 +08:00
总工andmultica-agent 225fcb49ea fix(ui): edited-draft marker uses amber warn per HEL-658 ruling (HEL-657)
Co-authored-by: multica-agent <github@multica.ai>
2026-09-30 08:49:53 +08:00
施工员andmultica-agent 70a860ce2a fix(ui): split running/awaiting-confirm colors and neutral kind tags (HEL-651 A batch)
- project card stage segments: running -> --run, awaiting confirm -> --warn
- running counters/dots and 'testing' states -> --run; warn-named notes -> --warn
- image/video/usage kind tags and plain entry/card icons -> neutral tokens

Co-authored-by: multica-agent <github@multica.ai>
2026-09-30 08:38:11 +08:00
施工员andmultica-agent e42212a16f feat(ui): fusion design tokens and shared component reskin (HEL-651 A batch)
- app.css: HEL-649 §一 tokens (bg/line/accent/status/text/type/space/radius/shadow/motion), legacy names aliased, reduced-motion via prefers-reduced-motion and data-reduce
- shared .a-btn/.status-chip/.a-panel/inputs reskinned; new .a-tag/.a-progress/.a-sprocket/.a-stage/.a-float
- tailwind colors/radii reference the same CSS variables
- ui primitives (StatusChip/ProgressBar/Modal/Button/ConfirmDialog/Skeleton/Switch/Card/Toast) on tokens
- running state -> --run, awaiting confirm -> --warn, --cloud purple removed, old palette literals -> tokens

Co-authored-by: multica-agent <github@multica.ai>
2026-09-30 08:29:37 +08:00
总工 f5f19f0366 merge: HEL-643 import preflight, real test runs and media groups 2026-09-29 19:12:18 +08:00
总工 bfe7b20b73 merge: HEL-641 image deletion and video history click-to-play 2026-09-29 19:12:08 +08:00
总工andmultica-agent 2ed9d25c24 feat(workflows): import preflight, real test runs and media groups (HEL-643)
- L2 连接核查: /object_info preflight (node existence + combo values) with
  5-min cache + manual refresh; analyze accepts endpoint_id, saved workflows
  get POST /{id}/check; unreachable degrades to 无法核查, never blocks saving
- 测试工作流: jobs.origin='workflow_test' rides the real queue → ComfyUI →
  outputs pipeline; only a downloadable, correctly-typed output counts as a
  pass; dry-run refused; repeat clicks reuse the in-flight job; artifacts
  isolated under assets/_workflow_test/{id}; retention keeps newest 5;
  clear-tests + delete-workflow cascade cleanup; tasks page shows a 测试 badge
- Media groups: Autogrow detection generalized to video/audio lists
  (ref_videos/ref_audios etc.), patcher clones/rewires per media family,
  validation per list kind; entries appear only for actually-wired slots;
  MiniMax H3 Ref2VA official-limit hints keyed off the node class
- 25 new backend tests (preflight / workflow-test / media-groups) against the
  fake ComfyUI; fake server gains /object_info + drop_outputs knobs

Co-authored-by: multica-agent <github@multica.ai>
2026-09-29 19:07:20 +08:00
总工andmultica-agent 48e16ae443 feat(studio): image result deletion and video history click-to-play (HEL-641)
Co-authored-by: multica-agent <github@multica.ai>
2026-09-29 17:48:00 +08:00
施工员andmultica-agent 319c7b049b fix: nine review follow-ups for connections & workflow foundation (HEL-619)
1. .form-col border-box — 384px is the outer width (a6 spec)
2. import preview counts all analyzed inputs, matching inline/studio counts
3. SSRF guard also runs on endpoint save (create + patch)
4. schemeless/garbled URL is a 400 format error, not a DNS-classified probe failure
5. ComfyUI health probe timeout 120s → 5s (matches the test probe)
6. workflow delete refuses (409) while pending/running jobs use it; delete errors surfaced in the UI
7. agent_llm_assignments: per-role incremental merge server-side + single-role delta payloads
8. test_shell_tool now portable (interpreter on PATH; PYTHON+approval instead of ffprobe)
9. test_video_continue pauses the queue singleton via monkeypatch (no leak into test_workbench)

Co-authored-by: multica-agent <github@multica.ai>
2026-09-29 16:13:00 +08:00
总工andmultica-agent e3bfe771af feat(workflows): manual output-node mapping for unknown custom outputs (HEL-638)
Unknown custom output nodes are never auto-typed by class-name semantics;
they stay pending until the user designates the real output node(s) and
media type. Picking a usage alone no longer enables a workflow, reanalyze/
migration/field edits never overwrite a user mapping, generation harvests
only the mapped nodes from ComfyUI history, and media-type mismatches or
missing node results fail for real instead of faking completion.

Co-authored-by: multica-agent <github@multica.ai>
2026-09-29 14:44:37 +08:00
总工andmultica-agent e6f761f8c8 feat(workflows): 五类用途识别与导入确认闭环 (HEL-636)
- 判型器 classify_usage:按输出节点(sink 检测)与被消费图片槽判定
  文生图/图生图/文生视频/图生视频/参考图生视频,不依赖文件名/模型名/
  固定节点 ID;歧义(单散装图+视频、同图双输出)与无输出给中文说明
- 参考图组泛化:任意 <group>.<prefix>N 连续编号且均接图源的输入成组
  (MiniMaxH3 特判仅剩标签美化);patcher 支持通用组回填
- workflows 表新增 usage/usage_source,幂等迁移回填;判不出的存量行
  停用待确认;kind 语义收窄为输出媒体,历史无损
- 导入改两步向导:分析→预览(判定依据/输出节点/媒体槽角色可改/
  参数预览/警告)→确认保存;usage_source=user 的纠正不被重分析覆盖
- 生成页按 usage 过滤;待确认工作流不进生成页且提交被拒
- 新增 test_usage_classifier / test_workflow_import_confirm;
  两个旧测试夹具补输出节点以匹配新守卫

Co-authored-by: multica-agent <github@multica.ai>
2026-09-29 12:13:33 +08:00
30e8e85766 feat(studio): 三种图片输入工作流与最多九张参考图 (HEL-633)
- 解析:H3 参考生视频 ref_images 自增输入收拢为 1–9 张有序参考图组;
  图生视频首/尾帧、Qwen 编辑输入图按真实连线识别,未连线不出槽位;
  Qwen 编辑模式暴露 resolution
- 打补丁:按张数克隆/复用/删除 LoadImage 并按序接线 ref_image_0..N-1
- 校验:参考图上限 9、至少 1 张、格式/存在性;必填图片槽位
- 工作流体检:缺 class_type / 断链 / 采样器无模型 → 导入时中文告警,
  生成接口与 worker 拒绝执行并提示重新导出
- 前端:RefImageGrid(逐张添加/预览/替换/删除/序号/超限整批拒绝)、
  MediaSlot 横排已选态、分辨率字段、损坏工作流提示、连接页告警标签
- 测试:三份真实 JSON + 1/4/9 张参考图全链路

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-29 09:16:17 +08:00
60c043db7c fix(HEL-627): 补齐真实工作流参数并修正虚假完成
- 解析 ResolutionSelector 比例/总像素、KSampler/BasicScheduler 降噪,附带 ComfyUI 取值范围
- 已导入的自动识别工作流读取时按最新规则重新解析,无需重导
- 提交前后端校验参数(中文原因),非法值 400 拒绝不入队
- 任务新增 phase / comfy_prompt_id:连接中→已接收(前方排队)→执行中→保存中→完成
- 空产出、队列与历史都丢失、执行中断均判失败;Dry-run 结果标为占位,不算完成
- ComfyUI 缺模型报错写明缺哪个文件
- 生图/生视频/任务页统一用 jobStatusInfo 展示真实状态,结果区支持复用参数

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-28 23:05:34 +08:00
0031a897d7 fix(HEL-623): split dual-prompt text encoders; positive wiring wins; detect duration via expression
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-28 18:57:00 +08:00
shigongyuan-agentandmultica-agent 063a3c7063 fix: untagged ComfyUI workflows expose inputs; studio generate always gives feedback (HEL-623)
Root cause: parse_dynamic_inputs only recognised (Input:role) title tags.
A workflow exported straight from ComfyUI has default titles, so its
input_schema was empty and the studio form rendered no prompt box; the
generate click then enqueued (or was blocked) with no toast at all.

- parser: untagged fallback detects prompt/negative (via wiring to
  sampler positive/negative), seed/steps/cfg, latent width/height/batch,
  numeric primitives, LoadImage/LoadImageMask/video/audio slots; only the
  prompt is required; entries carry auto=true
- patcher: "<nodeId>@<widget>" keys target one widget on multi-widget
  nodes, never overwrite linked inputs; LoadImageMask uploads like images
- workflows API re-derives an empty stored schema (old imports fixed
  without re-import); smart_fill sort accepts subgraph/field ids
- studio: blocked submit toasts the reason and scrolls to the field;
  success toasts "已提交"; submit errors show the API detail; job.failed
  for this window's jobs toasts the reason; no-param workflows show a
  note + disabled prompt box; auto-detected params show a note
- connections import: chip shows node count, toast reports real params
- Chinese messages for ComfyUI rejection/upload/timeout/execution errors
  and playground workflow-not-found/disabled
- tests: native Qwen t2i / Qwen edit+mask / untagged H3 fixtures, patch
  keys, backfill, full fake-ComfyUI chain, Chinese rejection

Co-authored-by: multica-agent <github@multica.ai>
2026-09-28 18:25:41 +08:00
施工员andmultica-agent 1a7a682cd0 HEL-615: 全部页面统一为 A「片场暗房」界面
- 设置页重写进 AppShell:生成与队列 / 智能体运行 / 存储路径三段,其余设置入口链到模型设置、ComfyUI 连接、智能体;旧 ?tab= 链接跳转到对应新页
- 项目卡状态标签改为按五段真实进度显示(已完成 / 某阶段生成中·待确认 / 进行中 / 待开始)
- 首页生成任务行对齐 a1(来源标签、项目名、进度条、本地时间)
- 连接页:右栏默认新增表单 + 任务使用目标面板,进入页面自动实测各连接;模型页默认新增表单、自动 1-token 实测,去掉双加号
- 生图/生视频:生成参数面板标题、通栏生成按钮、成片预览、按类型的历史标题与 16:9 视频历史、顶栏连接状态胶囊,连接状态改用服务器实测
- 智能体页:五个 Agent / Skill 库 / 已装 Skill 面板标题,内置 Skill 中文名,去掉对话空态 Calliope 字样
- 侧栏底部 ComfyUI 状态修正为真实实测(原查询不响应导致一直测试中)
- 统一样式来源:.a-btn/.a-panel/.a-select/.status-chip 等只在 app.css 定义一次,删除各页重复定义;新增 Switch、Button block、ProgressBar tone
- 旧 Calliope 路由(/projects、/project/:id、/canvas、/library、/build-scene、/playground)重定向到 A 页面;页面标题改为影工坊
- 轮询间隔单位改为秒(7 种语言)

Co-authored-by: multica-agent <github@multica.ai>
2026-09-28 16:25:55 +08:00
总工 d2a8ebf7dd merge: HEL-614 five-stage creation workbench into test branch (06b15dc) 2026-09-28 14:59:42 +08:00
总工andmultica-agent 06b15dc3b6 feat: five-stage creation workbench (HEL-614)
- backend: project_stages/stage_runs tables; /api/workbench router
  (overview, run agent with confirmed-upstream context only, edit draft,
  confirm, restore, script import, in-flow image/video generation via the
  shared queue with source=workbench); project stats gain real
  stage_confirmed/stage_status/active_jobs
- frontend: /workbench picker + /workbench/[id] five-stage page (stepper,
  per-stage editors, style direction chooser, storyboard table, asset
  prompt cards with in-flow generation, shot video prompts with player,
  agent + task side rail); nav 创作工作台 now opens it; home entries go
  through the workbench (导入剧本 skips 剧本创作); project cards show
  real five-stage progress; i18n keys in all seven dictionaries
- tests: 9 backend workbench tests (flow handoff, edit/confirm/restore,
  import, generation, Chinese failure states)

Co-authored-by: multica-agent <github@multica.ai>
2026-09-28 13:30:19 +08:00
总工 eeafe739f7 merge: HEL-613 five named agents chat + custom skills into test branch (rework 88660cb) 2026-09-28 12:13:42 +08:00
shigongyuan-agentandmultica-agent 88660cb3b4 fix: strip SKILL.md frontmatter in the skill editor (HEL-613 rework)
The editor's strip regex anchored on \\A — a Python-ism JS treats as a
literal — so it never matched: the editor showed the raw frontmatter and
every save re-wrapped the file in one more frontmatter layer. Extract the
strip into lib/skillFrontmatter.ts with a ^ anchor, import it from the
page, and guard it with scripts/check-skill-frontmatter.mjs (wired into
npm test): strips the render_skill_md block shape, exact split boundary,
idempotent, save-reload round-trip adds no layer, and the inline \\A
pattern is banned statically.

Also per review notes: skill toggles are now the spec-v2 §5 34x19 capsule
(amber track + dark knob when on), and version-history timestamps render
localized instead of the raw SQLite UTC string.

Co-authored-by: multica-agent <github@multica.ai>
2026-09-28 12:03:20 +08:00
shigongyuan-agentandmultica-agent cdd6eba8ba feat: five named agents independent chat + custom skill management (HEL-613)
Backend:
- agents_registry: five AIGC roles (剧本创作/视觉设计/分镜设计/素材资产/镜头脚本)
  with zh personas, agent-chat mode text, and llm_role mapping onto the
  models-page five-role binding surface (story/style/script/assets/video)
- agent_sessions.agent_key column + migration; sessions filter/exclusion;
  named replies stamped with the owner agent's display name
- prompt assembly: per-agent persona + AGENT CHAT mode section replace the
  generic sandbox text; mentions section suppressed for agent chats
- main loop routes the LLM through the agent's bound role
- skill admin surface: skill_overrides/skill_bindings/skill_versions tables,
  PUT/PATCH/DELETE /api/agent/skills/{name} + bindings + versions/restore;
  effective rule = global enable ∩ enabled per-agent binding; builtin skills
  seeded to all five agents at upgrade; read_skill/list_skills gated for
  named-agent sessions

Frontend:
- /agents is now the three-column A-darkroom page (agent list + skill library
  / chat with reused AgentChat / skill manager with editor, assignment
  capsules, version history + rollback); per-agent model select synced with
  the models page; narrow breakpoint turns the manager into a drawer
- legacy ?project=&task= deep links still route into the canvas flow
- i18n keys added to all 7 locales

Tests: tests/test_agent_roles.py + tests/test_skill_admin.py (27 cases)
Co-authored-by: multica-agent <github@multica.ai>
2026-09-28 11:28:26 +08:00
shigongyuan-agent 7be478dafd merge: HEL-612 standalone image/video studio + tasks page into test branch (HEL-620) 2026-09-28 10:17:45 +08:00
shigongyuan-agentandmultica-agent d49797fe5d fix: home tasks note no longer claims the jobs page is under development (HEL-620 review)
Co-authored-by: multica-agent <github@multica.ai>
2026-09-28 10:17:40 +08:00
587 changed files with 102535 additions and 36888 deletions
+2 -1
View File
@@ -156,7 +156,8 @@
"job.started",
"shot.capture.saved",
"shot.updated",
"story.ready"
"story.ready",
"workbench.stage.updated"
],
"prompt_profiles": [
"minimax_h3_ref",
@@ -0,0 +1,128 @@
"""Regenerate ``src/calliope/comfyui/ui_widget_orders.py`` (HEL-681).
Offline widget-order registry for the UI→API converter. Sources, in priority:
1. ``widgets_values_named`` instances across ``example_ComfyUI_workflows``
(including subgraph inner nodes) — the frontend serializes named widgets
in widget order, so the key order IS the serialization order;
2. the repo's 10 ground-truth API exports (root ``*_API.json``) — scalar
input names in export order (control_after_generate entries are absent
there; the converter's walk skips them positionally).
Run from anywhere inside the repo:
python scripts/build_widget_orders.py
"""
from __future__ import annotations
import collections
import json
from pathlib import Path
BACKEND = Path(__file__).resolve().parents[1]
REPO = BACKEND.parent
EXAMPLES = REPO / "example_ComfyUI_workflows"
OUT = BACKEND / "src" / "calliope" / "comfyui" / "ui_widget_orders.py"
BUTTONS = {"preview", "audioUI", "compare_view", "ToggleableColorPicker", "imagebrowser"}
MAX_SIGNATURES = 3
MAX_ORDER_LEN = 48
# Hand-curated fallbacks for node classes the corpus never serializes with
# names (classic core/custom widgets whose order is stable and public).
CURATED: dict[str, list[list[str]]] = {
"PrimitiveNode": [["value"]],
"Seed (rgthree)": [["seed"]],
}
def _iter_ui_nodes() -> list[dict]:
nodes: list[dict] = []
for path in sorted(EXAMPLES.rglob("*.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
continue
if not (isinstance(data, dict) and isinstance(data.get("nodes"), list)):
continue
nodes.extend(n for n in data["nodes"] if isinstance(n, dict))
for sub in data.get("definitions", {}).get("subgraphs", []) or []:
if isinstance(sub, dict):
nodes.extend(n for n in sub.get("nodes", []) or [] if isinstance(n, dict))
return nodes
def _iter_api_workflows() -> list[dict]:
out: list[dict] = []
for path in sorted(EXAMPLES.glob("*_API.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
continue
if isinstance(data, dict) and "nodes" not in data:
out.append(data)
return out
def main() -> None:
learned: dict[str, collections.Counter[tuple[str, ...]]] = collections.defaultdict(
collections.Counter
)
for node in _iter_ui_nodes():
named = node.get("widgets_values_named")
if not isinstance(named, dict) or not named:
continue
keys = tuple(
str(k) for k in named.keys() if k and k not in BUTTONS
)
if keys and len(keys) <= MAX_ORDER_LEN:
learned[str(node.get("type") or "")][keys] += 1
for workflow in _iter_api_workflows():
for node in workflow.values():
if not isinstance(node, dict):
continue
names = [
str(k)
for k, v in (node.get("inputs") or {}).items()
if not isinstance(v, list) and k and k not in BUTTONS
]
if len(names) > MAX_ORDER_LEN:
continue
# Display nodes (PreviewAny…) carry zero scalar inputs — that
# empty order is real signal: their positional arrays are pure
# button leftovers and map to {}.
learned[str(node.get("class_type") or "")][tuple(names)] += 1
registry: dict[str, list[list[str]]] = {}
for node_type, counter in sorted(learned.items()):
if not node_type:
continue
orders: list[list[str]] = []
for signature, _count in counter.most_common(MAX_SIGNATURES):
orders.append(list(signature))
if orders:
registry[node_type] = orders
for node_type, orders in CURATED.items():
registry.setdefault(node_type, orders)
body = json.dumps(registry, ensure_ascii=False, indent=1, sort_keys=True)
OUT.write_text(
'"""GENERATED by scripts/build_widget_orders.py — do not edit by hand.\n'
"\n"
"Widget-order registry for the UI→API converter: class_type → candidate\n"
"widget name orders, learned from the repo's named-widget UI files and\n"
"ground-truth API exports (HEL-681).\n"
'"""\n'
"\n"
"from __future__ import annotations\n"
"\n"
f"WIDGET_ORDERS: dict[str, list[list[str]]] = {body}\n",
encoding="utf-8",
)
total = sum(len(o) for orders in registry.values() for o in orders)
print(f"wrote {OUT.name}: {len(registry)} node types, {total} orders total")
if __name__ == "__main__":
main()
@@ -0,0 +1,170 @@
"""Generate the HEL-681 dry-run report over the repo's UI workflow library.
Walks ``example_ComfyUI_workflows`` (or ``--root``), runs every file through
the production converter + lint + parser pipeline (exactly what
``POST /api/workflows/convert`` does, minus HTTP), and writes a JSON detail
file plus a human-readable Markdown summary. Read-only: nothing is imported,
no DB is touched.
Usage:
python scripts/ui_import_dryrun.py [--root DIR] [--out PREFIX]
"""
from __future__ import annotations
import argparse
import collections
import json
import sys
from pathlib import Path
BACKEND = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(BACKEND / "src"))
from calliope.comfyui.lint import workflow_warnings # noqa: E402
from calliope.comfyui.metadata import suggested_metadata # noqa: E402
from calliope.comfyui.parser import classify_usage # noqa: E402
from calliope.comfyui.ui_convert import convert_ui_workflow # noqa: E402
REPO = BACKEND.parent
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--root", default=str(REPO / "example_ComfyUI_workflows"))
parser.add_argument("--out", default=str(REPO / "dryrun_report"))
args = parser.parse_args()
root = Path(args.root)
out_prefix = Path(args.out)
entries = []
for path in sorted(root.rglob("*.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as exc:
entries.append(
{
"source_path": str(path.relative_to(root)),
"status": "fail",
"issues": [{"code": "unreadable", "message": str(exc)}],
}
)
continue
if not (isinstance(data, dict) and isinstance(data.get("nodes"), list)):
continue # API-format / non-workflow files are out of scope
rel = str(path.relative_to(root))
result = convert_ui_workflow(data)
entry: dict = {
"source_path": rel,
"status": result.status,
"needs_confirmation": result.needs_confirmation,
"issues": [
{"node_id": i.node_id, "code": i.code, "level": i.level, "message": i.message}
for i in result.issues
],
}
if result.workflow is not None:
entry["node_count"] = len(result.workflow)
entry["suggested"] = suggested_metadata(result.workflow, rel, data)
errors = [w for w in workflow_warnings(result.workflow) if w["level"] == "error"]
entry["lint_errors"] = [e["code"] for e in errors]
entry["importable"] = not result.needs_confirmation and not errors
else:
entry["importable"] = False
entries.append(entry)
counts = collections.Counter(e["status"] for e in entries)
code_counts = collections.Counter(
i["code"] for e in entries for i in e["issues"]
)
category_counts = collections.Counter(
(e.get("suggested") or {}).get("category") or "?" for e in entries
)
family_counts = collections.Counter(
(e.get("suggested") or {}).get("model_family") or "?" for e in entries
)
importable = sum(1 for e in entries if e.get("importable"))
needs_confirmation = sum(1 for e in entries if e["needs_confirmation"])
lint_blocked = [e for e in entries if e.get("lint_errors")]
detail = {
"root": str(root),
"total": len(entries),
"status_counts": dict(counts),
"issue_code_counts": dict(code_counts),
"importable": importable,
"needs_confirmation": needs_confirmation,
"lint_blocked": [e["source_path"] for e in lint_blocked],
"files": entries,
}
out_prefix.parent.mkdir(parents=True, exist_ok=True)
(out_prefix.with_suffix(".json")).write_text(
json.dumps(detail, ensure_ascii=False, indent=1), encoding="utf-8"
)
lines = [
"# HEL-681 工作流 UI→API 转换 dry-run 报告",
"",
f"- 扫描目录:`{root}`(根目录 10 个 API 格式旧文件不在本管线范围)",
f"- UI 格式文件总数:**{len(entries)}**",
f"- 转换结果:ok **{counts.get('ok', 0)}** / warn **{counts.get('warn', 0)}** / fail **{counts.get('fail', 0)}**",
f"- 其中「需人工确认」(控件无法可靠映射,禁止直接导入):**{needs_confirmation}**",
f"- 通过转换+lint 后可直接导入:**{importable}**",
f"- 转换成功但被现有 lint 拦截(导入时按阻止处理):**{len(lint_blocked)}**",
"",
"## 分类分布(自动提取)",
"",
"| 分类 | 数量 |",
"|---|---|",
]
for cat, n in sorted(category_counts.items()):
lines.append(f"| {cat} | {n} |")
lines += ["", "## 模型家族分布(自动提取)", "", "| 家族 | 数量 |", "|---|---|"]
for fam, n in sorted(family_counts.items(), key=lambda kv: -kv[1]):
lines.append(f"| {fam} | {n} |")
lines += ["", "## 问题代码统计", "", "| 代码 | 次数 | 含义 |", "|---|---|---|"]
meaning = {
"muted_input_dropped": "上游节点被静音,输入按未连接处理",
"variable_upstream_dropped": "Set/Get 变量上游被旁路,取值按未连接处理",
"get_without_set": "GetNode 没有同名 SetNode",
"widget_unmapped": "控件值无法可靠映射到输入名(需人工确认)",
"widget_order_mismatch": "控件数量与已知顺序不符(需人工确认)",
"no_output_node": "图中无已知输出节点,全部活跃节点保留待确认",
"cyclic_variable": "Set/Get 循环(FOR 循环流),该连线按未连接处理",
"dangling_link": "连线指向不存在的节点",
}
for code, n in code_counts.most_common():
lines.append(f"| `{code}` | {n} | {meaning.get(code, '')} |")
lines += ["", "## 需人工确认文件清单", ""]
for e in entries:
if e["needs_confirmation"]:
reasons = sorted({i["code"] for i in e["issues"]})[:3]
lines.append(f"- `{e['source_path']}`({', '.join(reasons)})")
lines += ["", "## lint 拦截文件清单", ""]
for e in lint_blocked:
lines.append(f"- `{e['source_path']}`({', '.join(e['lint_errors'])})")
lines += [
"",
"## 逐文件结果",
"",
"| 文件 | 状态 | 节点数 | 分类 | 模型家族 | 用途 | 问题 |",
"|---|---|---|---|---|---|---|",
]
for e in entries:
sug = e.get("suggested") or {}
issues = collections.Counter(i["code"] for i in e["issues"])
issue_text = ", ".join(f"{c}×{n}" for c, n in issues.most_common(2)) or "-"
usage = sug.get("usage") or ("待定" if sug.get("usage_ambiguous") else "-")
lines.append(
f"| `{e['source_path']}` | {e['status']} | {e.get('node_count', '-')} "
f"| {sug.get('category') or '-'} | {sug.get('model_family') or '-'} "
f"| {usage} | {issue_text} |"
)
(out_prefix.with_suffix(".md")).write_text("\n".join(lines) + "\n", encoding="utf-8")
print(f"{len(entries)} files: {dict(counts)}, importable={importable}, nc={needs_confirmation}")
print(f"report → {out_prefix}.md / {out_prefix}.json")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,165 @@
"""Named-agent registry (HEL-613): the five AIGC roles that back both the
「智能体」page's independent chat sessions and (in a later card) the five-stage
creation flow.
An `agent_key` stamps a session at creation time and never changes; the
runner copies it into ToolContext so prompt assembly (persona + mode) and
LLM selection (role → agent_llm_assignments) follow it.
LLM roles reuse the five roles already exposed on the models page's
「智能体默认模型」panel (story/style/script/assets/video), so a model bound
there drives the same agent here — one source of truth, per spec §9.4.
"""
from __future__ import annotations
from typing import Any
AgentDef = dict[str, Any]
# Order matters: it is the display order in the left column (spec §4.3).
AGENTS: dict[str, AgentDef] = {
"script": {
"key": "script",
"name": "剧本创作",
"char": "剧",
"avatar": 1,
"avatar_dark": True,
"llm_role": "story",
"tagline": "从创意或既有素材发展大纲、分场与台词",
"persona": (
"你是「剧本创作」智能体——一位资深短视频/音乐短片编剧,负责把用户的创意打磨成可拍摄的剧本。\n"
"- 职责:产出故事大纲、分场结构(场次/场景/时间)、人物小传、台词与动作描述;"
"用户提供现成剧本时,先读懂再帮用户修改、补全或提炼。\n"
"- 产出要求:结构清晰(大纲→分场→台词逐层展开),台词口语化、有情绪;"
"关键设定(人物、场景、时代、基调)显式列出,方便后续环节复用。\n"
"- 你是创作顾问:有取舍分歧时给出方案与理由,请用户拍板,不自作主张定稿。\n"
"- 默认用简体中文回复。这是独立会话,不参与五段创作流程;需要视觉/分镜/素材建议时,"
"建议用户去对应智能体会话继续。"
),
},
"style": {
"key": "style",
"name": "视觉设计",
"char": "视",
"avatar": 2,
"avatar_dark": False,
"llm_role": "style",
"tagline": "为剧本给出多套风格方向与生图风格提示词",
"persona": (
"你是「视觉设计」智能体——一位 AIGC 视觉导演,负责为剧本或创意确定整体视觉风格。\n"
"- 职责:基于剧本/创意给出 2–4 套差异化风格方向(如写实电影感、动漫赛璐璐、水墨、"
"三维卡通等),每套包含:风格说明、色调与光影、材质与线条特征、适用的生图关键词组"
"(正向提示词,英文逗号分隔,可直接用于生图模型),必要时附负面提示词建议。\n"
"- 产出要求:风格方向用清晰的分段呈现;提示词词组完整可复制;说明每套风格适合的"
"题材与镜头类型。\n"
"- 你是顾问:推荐倾向可以给,最终风格由用户选择。\n"
"- 默认用简体中文回复;提示词词组本身用英文(生图模型惯例),并附中文说明。"
"这是独立会话,不参与五段创作流程。"
),
},
"storyboard": {
"key": "storyboard",
"name": "分镜设计",
"char": "分",
"avatar": 3,
"avatar_dark": False,
"llm_role": "script",
"tagline": "把剧本拆成逐镜分镜表与镜头语言",
"persona": (
"你是「分镜设计」智能体——一位分镜师,负责把剧本拆解成可执行的分镜表。\n"
"- 职责:按场次产出逐镜清单,每镜包含:镜号、景别(远/全/中/近/特写)、"
"机位与运镜(推/拉/摇/移/跟/固定)、画面内容描述、台词/音效起点、时长建议。\n"
"- 产出要求:表格或逐镜列表,信息完整可直接交给素材与视频环节;"
"运镜术语规范,节奏有起伏,重要情绪点给特写。\n"
"- 有多种可行拆法时,先给一版主推方案,再简述备选思路,请用户确认。\n"
"- 默认用简体中文回复。这是独立会话,不参与五段创作流程。"
),
},
"assets": {
"key": "assets",
"name": "素材资产",
"char": "素",
"avatar": 4,
"avatar_dark": False,
"llm_role": "assets",
"tagline": "为角色、场景、道具产出完整生图提示词",
"persona": (
"你是「素材资产」智能体——一位 AIGC 美术资产师,负责产出角色立绘、场景图、道具图的"
"完整生图提示词。\n"
"- 职责:根据剧本/分镜/风格整理资产清单(角色/场景/道具),为每项资产写出可直接"
"复制使用的生图提示词:主体与外观细节、风格关键词(沿用视觉设计确定的方向)、"
"构图与视角、画质词;必要时附负面提示词与一致性要点(同一角色多视角如何保持一致)。\n"
"- 产出要求:按资产逐条给出,提示词完整、自成一体、可复制;英文提示词附中文说明。\n"
"- 资产数量多时先给清单让用户确认优先级。\n"
"- 默认用简体中文回复。这是独立会话,不参与五段创作流程。"
),
},
"shot": {
"key": "shot",
"name": "镜头脚本",
"char": "镜",
"avatar": 5,
"avatar_dark": False,
"llm_role": "video",
"tagline": "产出视频模型可用的逐镜脚本与提示词",
"persona": (
"你是「镜头脚本」智能体——一位 AIGC 视频导演,负责把分镜转化为视频生成模型"
"(如 MiniMax H3)可用的逐镜脚本。\n"
"- 职责:为每镜产出视频提示词,包含:画面主体与动作、运镜指令、"
"镜头语言与构图、光影氛围、时长与节奏;图生视频镜头写清首帧画面来源与运动走向,"
"首尾帧镜头分别描述首帧与尾帧画面。\n"
"- 产出要求:逐镜编号、结构统一、提示词完整可复制;同一项目的运镜与风格保持一致。\n"
"- 涉及参考视频/遮罩等输入时,说明每镜需要准备的素材。\n"
"- 默认用简体中文回复;视频提示词主体用英文(视频模型惯例),并附中文说明。"
"这是独立会话,不参与五段创作流程。"
),
},
}
AGENT_KEYS: tuple[str, ...] = tuple(AGENTS.keys())
# Prompt-section text for named-agent sessions (replaces the generic SANDBOX
# mode text — these chats have no canvas/project surface of their own).
def mode_text(agent_key: str) -> str:
agent = AGENTS.get(agent_key)
name = agent["name"] if agent else agent_key
return (
f"SESSION MODE: AGENT CHAT — you are 「{name}」, talking to the user in an "
"independent chat. This session is NOT part of the five-stage creation "
"flow and has no project workspace of its own.\n"
"- Stay in your professional role; do the thinking and writing yourself.\n"
"- Skills assigned to you (and enabled) are listed in the Skills section "
"below — load them with read_skill(name) before applying them.\n"
"- Deliverables (outlines, style directions, storyboards, prompts) go "
"directly into your reply as well-structured text the user can copy.\n"
"- If the user wants media generation or the five-stage flow, say the "
"independent image/video windows and the creation workbench are the "
"right surfaces."
)
def persona_text(agent_key: str) -> str | None:
agent = AGENTS.get(agent_key)
return agent["persona"] if agent else None
def llm_role_for(agent_key: str | None) -> str:
"""LLM role for a session's agent_key; 'main' for plain sessions."""
agent = AGENTS.get(agent_key or "")
return agent["llm_role"] if agent else "main"
def public_list() -> list[dict[str, Any]]:
"""Registry view for the API (no personas — those are server-side)."""
return [
{
"key": a["key"],
"name": a["name"],
"char": a["char"],
"avatar": a["avatar"],
"avatar_dark": bool(a.get("avatar_dark")),
"llm_role": a["llm_role"],
"tagline": a["tagline"],
}
for a in AGENTS.values()
]
@@ -0,0 +1,52 @@
"""Capture successful task replies without treating chat/errors as confirmed work."""
from __future__ import annotations
from calliope.config import settings
from calliope.db import get_db
def complete_task(session_id: int, final: str) -> None:
from calliope.agent.harness.plugins.interaction import latest_open_question
conn = get_db(settings.db_path)
try:
if latest_open_question(session_id):
conn.execute(
"UPDATE agent_sessions SET task_status='awaiting_input' WHERE id=?", (session_id,)
)
else:
session = conn.execute(
"SELECT * FROM agent_sessions WHERE id=?", (session_id,)
).fetchone()
message = conn.execute(
"SELECT id FROM agent_messages WHERE session_id=? AND role='assistant' "
"AND content=? AND (status IS NULL OR status NOT IN ('error','cancelled')) "
"ORDER BY id DESC LIMIT 1",
(session_id, final),
).fetchone()
if session and message and final.strip():
conn.execute(
"INSERT OR IGNORE INTO agent_deliverables "
"(session_id,source_message_id,project_id,stage_key,title,content) "
"VALUES (?,?,?,?,?,?)",
(
session_id,
message["id"],
session["project_id"],
session["agent_key"] or "script",
session["title"][:200],
final,
),
)
conn.execute(
"UPDATE agent_sessions SET task_status='awaiting_confirm' WHERE id=?",
(session_id,),
)
else:
conn.execute(
"UPDATE agent_sessions SET task_status='completed' WHERE id=?", (session_id,)
)
conn.commit()
finally:
conn.close()
@@ -480,7 +480,11 @@ async def run_turn(
}
)
client = _llm_for_role("main")
# Named-agent sessions (HEL-613) use the agent's bound LLM role; plain
# sessions keep 'main' (Active LLM unless a role assignment says otherwise).
from calliope.agent.agents_registry import llm_role_for
client = _llm_for_role(llm_role_for(getattr(ctx, "agent_key", None)))
final_text = ""
turn_status = "completed"
synthetic_idx: list[int] = [] # positions of budget-nudge messages (stripped after)
@@ -11,6 +11,7 @@ from typing import Any
from calliope.agent.harness.registry import ToolContext, ToolDefinition, ToolRegistry
from calliope.agent.skills_store import (
effective_skill_dirs,
ensure_builtin_skills,
list_skills as store_list_skills,
read_skill_file,
@@ -23,7 +24,12 @@ _PROMPT_CAP = 24
async def t_list_skills(ctx: ToolContext, args: dict[str, Any]) -> dict[str, Any]:
skills = store_list_skills()
agent_key = getattr(ctx, "agent_key", None)
if agent_key:
effective = effective_skill_dirs(agent_key)
skills = [s for s in store_list_skills(cap=None) if s["dir"] in effective]
else:
skills = store_list_skills()
return {"ok": True, "skills": skills, "count": len(skills)}
@@ -31,13 +37,28 @@ async def t_read_skill(ctx: ToolContext, args: dict[str, Any]) -> dict[str, Any]
ensure_builtin_skills()
name = str(args.get("name") or "").strip()
rel = str(args.get("path") or "SKILL.md").strip()
agent_key = getattr(ctx, "agent_key", None)
if agent_key:
# Named-agent sessions may only load skills assigned to them AND
# enabled (HEL-613 gating — the discovery list matches this rule).
effective = effective_skill_dirs(agent_key)
known = {
d for d in effective
} | {s["name"] for s in store_list_skills(cap=None) if s["dir"] in effective}
if name not in known:
return {
"ok": False,
"error": (
f"skill '{name}' is not assigned to this agent or is disabled"
),
}
return read_skill_file(name, rel)
def skills_prompt_text(ctx: ToolContext) -> str | None:
"""Discovery section: names + descriptions only (cheap; bodies load via
read_skill). None when there are no skills."""
lines = skills_prompt_lines()
lines = skills_prompt_lines(agent_key=getattr(ctx, "agent_key", None))
if not lines:
return None
return (
@@ -71,6 +71,11 @@ class SystemPromptService:
async def _persona_section(ctx: ToolContext) -> str | None:
from calliope.agent.agents_registry import persona_text
named = persona_text(getattr(ctx, "agent_key", None) or "")
if named:
return named
return _persona_text()
@@ -86,6 +91,13 @@ async def _mode_section(ctx: ToolContext) -> str | None:
def _mode_text(ctx: ToolContext) -> str:
agent_key = getattr(ctx, "agent_key", None)
if agent_key:
# Named-agent independent chat (HEL-613): not a canvas sandbox —
# replace the sandbox/project mode text with the agent-chat contract.
from calliope.agent.agents_registry import mode_text
return mode_text(agent_key)
if getattr(ctx, "origin", "chat") == "scene":
return (
"SESSION MODE: BUILD SCENE — this chat drives the 3D composition.\n"
@@ -284,6 +296,10 @@ async def _tool_discipline_section(ctx: ToolContext) -> str | None:
async def _mentions_section(ctx: ToolContext) -> str | None:
if getattr(ctx, "agent_key", None):
# Named-agent chat (HEL-613): the independent agents page has no
# workflow mention affordance; skills arrive via the Skills section.
return None
if getattr(ctx, "origin", "chat") == "scene":
return (
"Build Scene sessions ignore workflow tags for generation. "
@@ -75,11 +75,15 @@ class ToolContext:
None means a blind/sandbox session (only project-creating tools allowed).
`origin` stamps WHERE the session was born ('chat' | 'scene') — scene
sessions are Build Scene's blockout surface and get the shot-tool-only
payload; it says nothing about project linking."""
payload; it says nothing about project linking.
`agent_key` names WHICH of the five AIGC agents (剧本创作 etc.) owns the
session (HEL-613) — drives per-agent persona/mode prompt sections and the
LLM role. None = a plain harness session."""
session_id: int
project_id: int | None = None
origin: str = "chat"
agent_key: str | None = None
@dataclass
@@ -40,8 +40,10 @@ class AgentRunner:
conn = self._db()
try:
conn.execute(
"UPDATE agent_sessions SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(status, session_id),
"UPDATE agent_sessions SET status = ?, task_status=CASE "
"WHEN ? = 'running' THEN 'running' WHEN ? = 'error' THEN 'error' "
"ELSE task_status END, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(status, status, status, session_id),
)
conn.commit()
finally:
@@ -140,7 +142,7 @@ class AgentRunner:
finally:
conn.close()
def _make_message_sink(self, session_id: int):
def _make_message_sink(self, session_id: int, default_agent_name: str | None = None):
"""Persistence + broadcast sink for harness messages (planner notes,
sub-agent reports). Tool calls/results are already logged by the loop
via the event log — the sink only mirrors them into agent_messages."""
@@ -157,11 +159,16 @@ class AgentRunner:
"truncated": True,
"preview": text[: session_log.EVENT_DATA_TRUNCATE] + "…[truncated]",
}
agent_name = message.get("agent_name")
if not agent_name and default_agent_name and message.get("role") == "assistant":
# Named-agent sessions (HEL-613): main-loop replies carry no
# agent_name — stamp the owning agent so bubbles/labels match.
agent_name = default_agent_name
persisted = self._persist_message(
session_id,
role=message.get("role") or "assistant",
content=message.get("content") or "",
agent_name=message.get("agent_name"),
agent_name=agent_name,
tool_name=message.get("tool_name"),
tool_args=message.get("tool_args")
if isinstance(message.get("tool_args"), dict)
@@ -196,7 +203,8 @@ class AgentRunner:
).fetchall()]
if stuck:
conn.execute(
"UPDATE agent_sessions SET status = 'idle' WHERE status = 'running'"
"UPDATE agent_sessions SET status = 'idle', task_status='interrupted' "
"WHERE status = 'running'"
)
conn.commit()
logger.warning(
@@ -290,6 +298,15 @@ class AgentRunner:
finally:
conn.close()
conn = self._db()
try:
conn.execute(
"UPDATE agent_sessions SET task_summary=? WHERE id=?",
(user_message.strip()[:300], session_id),
)
conn.commit()
finally:
conn.close()
self._set_status(session_id, "running")
task = asyncio.create_task(
self._run_session(session_id), name=f"agent-session-{session_id}"
@@ -366,10 +383,12 @@ class AgentRunner:
conn = self._db()
try:
row = conn.execute(
"SELECT project_id, origin FROM agent_sessions WHERE id = ?", (session_id,)
"SELECT project_id, origin, agent_key FROM agent_sessions WHERE id = ?",
(session_id,),
).fetchone()
project_id = row["project_id"] if row else None
origin = (row["origin"] if row else None) or "chat"
agent_key = (row["agent_key"] if row else None) or None
finally:
conn.close()
@@ -377,14 +396,23 @@ class AgentRunner:
session_id=session_id,
project_id=project_id,
origin=origin,
agent_key=agent_key,
)
from calliope.agent.agents_registry import AGENTS
default_agent_name = (
AGENTS.get(agent_key or "", {}).get("name") if agent_key else None
)
try:
final = await orchestrate(
ctx,
[], # history derived inside from the event log
session_id=session_id,
on_message=self._make_message_sink(session_id),
on_message=self._make_message_sink(session_id, default_agent_name),
)
from calliope.agent.deliverables import complete_task
complete_task(session_id, final)
# Final answer is persisted + broadcast by the loop's emit() call
# (which goes through the message sink). Only set status here.
self._set_status(session_id, "idle")
@@ -395,6 +423,14 @@ class AgentRunner:
content="— run cancelled —",
status="cancelled",
)
conn = self._db()
try:
conn.execute(
"UPDATE agent_sessions SET task_status='cancelled' WHERE id=?", (session_id,)
)
conn.commit()
finally:
conn.close()
self._set_status(session_id, "idle")
raise
except Exception as exc: # noqa: BLE001 — surfaced to the chat as the reply
@@ -21,7 +21,24 @@ from calliope.config import settings
READ_CHAR_CAP = 8000
_LIST_CAP = 24
VERSIONS_PER_SKILL_CAP = 20
_FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n?", re.DOTALL)
_NAME_MAX = 64
# Skill types offered by the editor (spec §4.3): 提示词模板 / 参考知识 / 流程规则
SKILL_TYPES = ("template", "knowledge", "rules")
def _valid_dir_name(name: str) -> bool:
return bool(
name
and name not in (".", "..")
and "/" not in name
and "\\" not in name
and not name.startswith(".")
and len(name) <= _NAME_MAX
and all(ch not in name for ch in "\x00\r\n\t")
and name == name.strip()
)
def skills_root() -> Path:
@@ -110,36 +127,334 @@ def skill_summary(skill_dir: Path) -> dict[str, Any] | None:
if not meta:
return None
metadata = meta.get("metadata") if isinstance(meta.get("metadata"), dict) else {}
try:
updated = skill_file.stat().st_mtime
except OSError:
updated = None
skill_type = str(metadata.get("type") or "") if metadata else ""
return {
"name": str(meta.get("name") or skill_dir.name),
"description": str(meta.get("description") or "").strip(),
"version": str(meta.get("version") or ""),
"tags": metadata.get("tags") or [],
"dir": skill_dir.name,
"type": skill_type if skill_type in SKILL_TYPES else "",
"updated_at": updated,
}
def list_skills() -> list[dict[str, Any]]:
"""Frontmatter summaries for every `*/SKILL.md`, sorted by name."""
def list_skills(cap: int | None = _LIST_CAP) -> list[dict[str, Any]]:
"""Frontmatter summaries for every `*/SKILL.md`, sorted by name.
`cap=None` lists everything (the management surface); the default keeps
the historical 24-item ceiling for the discovery prompt/tool.
"""
ensure_builtin_skills()
root = skills_root()
if not root.is_dir():
return []
out: list[dict[str, Any]] = []
limit = cap # _LIST_CAP by default; explicit None = unlimited
for skill_dir in sorted(root.iterdir()):
if not skill_dir.is_dir():
continue
summary = skill_summary(skill_dir)
if summary:
out.append(summary)
if len(out) >= _LIST_CAP:
if limit is not None and len(out) >= limit:
break
return out
def skills_prompt_lines() -> list[str]:
"""`- name: description` lines for the agent's discovery section."""
return [f"- {s['name']}: {s['description']}" for s in list_skills()]
# ─────────────────────────────────────────────────────────────────────────
# Admin state (HEL-613): enable flags + per-agent assignment live in the DB;
# file content stays the single truth for what a skill IS.
# ─────────────────────────────────────────────────────────────────────────
def _admin_db():
from calliope.db import get_db
return get_db(settings.db_path)
def skill_enable_states() -> dict[str, bool]:
"""skill dir name → global enable (absent row = enabled)."""
conn = _admin_db()
try:
rows = conn.execute("SELECT skill_name, is_enabled FROM skill_overrides").fetchall()
return {r["skill_name"]: bool(r["is_enabled"]) for r in rows}
finally:
conn.close()
def skill_binding_map() -> dict[str, dict[str, bool]]:
"""skill dir name → {agent_key: enabled}."""
conn = _admin_db()
try:
rows = conn.execute(
"SELECT agent_key, skill_name, is_enabled FROM skill_bindings"
).fetchall()
out: dict[str, dict[str, bool]] = {}
for r in rows:
out.setdefault(r["skill_name"], {})[r["agent_key"]] = bool(r["is_enabled"])
return out
finally:
conn.close()
def set_skill_enabled(name: str, enabled: bool) -> None:
conn = _admin_db()
try:
conn.execute(
"INSERT INTO skill_overrides(skill_name, is_enabled) VALUES (?, ?) "
"ON CONFLICT(skill_name) DO UPDATE SET is_enabled = excluded.is_enabled",
(name, 1 if enabled else 0),
)
conn.commit()
finally:
conn.close()
def set_skill_bindings(name: str, bindings: dict[str, bool]) -> None:
"""Replace the assignment matrix for one skill (agent_key → enabled)."""
conn = _admin_db()
try:
from calliope.agent.agents_registry import AGENT_KEYS
conn.execute("DELETE FROM skill_bindings WHERE skill_name = ?", (name,))
for key in AGENT_KEYS:
if key in bindings:
conn.execute(
"INSERT INTO skill_bindings(agent_key, skill_name, is_enabled) "
"VALUES (?, ?, ?)",
(key, name, 1 if bindings[key] else 0),
)
conn.commit()
finally:
conn.close()
def effective_skill_dirs(agent_key: str | None) -> set[str]:
"""Dir names visible to a session: global enable ∩ enabled binding for
the agent. Plain sessions (agent_key None) see all globally-enabled
skills — the pre-HEL-613 behavior."""
enabled = skill_enable_states()
dirs = {s["dir"] for s in list_skills(cap=None) if enabled.get(s["dir"], True)}
if not agent_key:
return dirs
bindings = skill_binding_map()
out: set[str] = set()
for skill_dir in dirs:
if bindings.get(skill_dir, {}).get(agent_key) is True:
out.add(skill_dir)
return out
def skills_prompt_lines(agent_key: str | None = None) -> list[str]:
"""`- name: description` lines for the agent's discovery section,
filtered to the skills effective for that session's agent."""
effective = effective_skill_dirs(agent_key)
return [
f"- {s['name']}: {s['description']}"
for s in list_skills(cap=None)
if s["dir"] in effective
]
# ─────────────────────────────────────────────────────────────────────────
# Write / delete / versions
# ─────────────────────────────────────────────────────────────────────────
def _bump_version(prev: str) -> str:
prev = (prev or "").strip()
if not prev:
return "1.0.0"
m = re.match(r"(\d+)\.(\d+)\.(\d+)$", prev)
if m:
return f"{m.group(1)}.{m.group(2)}.{int(m.group(3)) + 1}"
return "1.0.1"
def render_skill_md(
name: str,
*,
description: str,
version: str,
tags: list[str] | None,
skill_type: str,
body: str,
) -> str:
desc = " ".join(description.split())[:500]
tag_line = ", ".join(str(t).strip() for t in (tags or []) if str(t).strip())
lines = ["---", f"name: {name}"]
lines.append(f'description: "{desc.replace(chr(34), chr(39))}"')
lines.append(f"version: {version or '1.0.0'}")
lines.append("metadata:")
lines.append(" author: user")
if tag_line:
lines.append(f" tags: [{tag_line}]")
if skill_type in SKILL_TYPES:
lines.append(f" type: {skill_type}")
lines.append("---")
text = "\n".join(lines) + "\n\n" + (body if body.endswith("\n") or not body else body + "\n")
return text
def snapshot_version(name: str, content: str) -> None:
"""File the current SKILL.md full text as an edit snapshot (cap 20)."""
conn = _admin_db()
try:
conn.execute(
"INSERT INTO skill_versions(skill_name, content) VALUES (?, ?)",
(name, content),
)
stale = conn.execute(
"SELECT id FROM skill_versions WHERE skill_name = ? ORDER BY id DESC "
"LIMIT -1 OFFSET ?",
(name, VERSIONS_PER_SKILL_CAP),
).fetchall()
for row in stale:
conn.execute("DELETE FROM skill_versions WHERE id = ?", (row["id"],))
conn.commit()
finally:
conn.close()
def list_versions(name: str) -> list[dict[str, Any]]:
conn = _admin_db()
try:
rows = conn.execute(
"SELECT id, skill_name, content, created_at FROM skill_versions "
"WHERE skill_name = ? ORDER BY id DESC",
(name,),
).fetchall()
return [
{
"id": r["id"],
"skill": r["skill_name"],
"created_at": r["created_at"],
"length": len(r["content"]),
"preview": r["content"][:160],
}
for r in rows
]
finally:
conn.close()
def version_content(name: str, version_id: int) -> str | None:
conn = _admin_db()
try:
row = conn.execute(
"SELECT content FROM skill_versions WHERE id = ? AND skill_name = ?",
(version_id, name),
).fetchone()
return row["content"] if row else None
finally:
conn.close()
def write_skill(
name: str,
*,
description: str,
version: str = "",
tags: list[str] | None = None,
skill_type: str = "",
body: str = "",
) -> dict[str, Any]:
"""Create/overwrite `<skills>/<name>/SKILL.md`. Snapshots the previous
file first (version history). Returns the new summary."""
if not _valid_dir_name(name):
raise ValueError(f"invalid skill name '{name}'")
ensure_builtin_skills()
skill_dir = skills_root() / name
skill_file = skill_dir / "SKILL.md"
prev_version = ""
if skill_file.is_file():
try:
old = skill_file.read_text(encoding="utf-8")
except OSError as exc:
raise ValueError(f"unreadable existing skill: {exc}") from exc
prev_meta = _parse_frontmatter(old)
prev_version = str(prev_meta.get("version") or "")
snapshot_version(name, old)
skill_dir.mkdir(parents=True, exist_ok=True)
new_version = (version or "").strip() or _bump_version(prev_version)
text = render_skill_md(
name,
description=description,
version=new_version,
tags=tags,
skill_type=skill_type,
body=body,
)
skill_file.write_text(text, encoding="utf-8")
summary = skill_summary(skill_dir)
return summary or {
"name": name,
"dir": name,
"description": description,
"version": new_version,
"tags": tags or [],
"type": skill_type,
"updated_at": None,
}
def restore_skill_file(name: str, content: str) -> dict[str, Any]:
"""Write a snapshot back verbatim (snapshotting the current file first,
so a restore is itself undoable). The snapshot carries its own
frontmatter — no re-render."""
if not _valid_dir_name(name):
raise ValueError(f"invalid skill name '{name}'")
skill_dir = skills_root() / name
skill_file = skill_dir / "SKILL.md"
if skill_file.is_file():
try:
old = skill_file.read_text(encoding="utf-8")
snapshot_version(name, old)
except OSError as exc:
raise ValueError(f"unreadable existing skill: {exc}") from exc
skill_dir.mkdir(parents=True, exist_ok=True)
skill_file.write_text(content, encoding="utf-8")
summary = skill_summary(skill_dir)
return summary or {
"name": name,
"dir": name,
"description": "",
"version": "",
"tags": [],
"type": "",
"updated_at": None,
}
def delete_skill(name: str) -> None:
"""Remove a skill dir (realpath-contained) + its DB state."""
if not _valid_dir_name(name):
raise ValueError(f"invalid skill name '{name}'")
skill_dir = skills_root() / name
if skill_dir.is_dir():
root_real = os.path.realpath(skills_root())
dir_real = os.path.realpath(skill_dir)
try:
contained = Path(dir_real).is_relative_to(Path(root_real))
except (OSError, ValueError):
contained = False
if not contained:
raise ValueError(f"invalid skill path '{name}'")
shutil.rmtree(dir_real)
conn = _admin_db()
try:
conn.execute("DELETE FROM skill_bindings WHERE skill_name = ?", (name,))
conn.execute("DELETE FROM skill_overrides WHERE skill_name = ?", (name,))
conn.execute("DELETE FROM skill_versions WHERE skill_name = ?", (name,))
conn.commit()
finally:
conn.close()
def skill_files(name: str) -> list[str] | None:
+141 -17
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
import time
import uuid
from pathlib import Path
from typing import Any
@@ -13,6 +14,22 @@ from calliope.comfyui.registry import IMAGE_CLASSES, AUDIO_CLASSES, VIDEO_CLASSE
logger = logging.getLogger("calliope.comfyui")
# Reachability probe budget (HEL-619): matches the connections-page test
# probe (5s). Without it health() inherits the 120s client default, so a
# bad endpoint hangs every queued job for two minutes before failing.
HEALTH_TIMEOUT_SEC = 5.0
# /object_info fetch is a few MB on big instances; the preflight check
# (HEL-642/643) caches it per endpoint for this long. Manual refresh
# (`force_refresh=True`) bypasses the TTL.
OBJECT_INFO_TTL_SEC = 300.0
_object_info_cache: dict[str, tuple[float, dict[str, Any]]] = {}
def clear_object_info_cache() -> None:
"""Drop cached /object_info payloads (tests swap fake endpoints in)."""
_object_info_cache.clear()
def _surface_error(prefix: str, resp: httpx.Response) -> RuntimeError:
"""Raise with ComfyUI's own error body instead of an opaque status line.
@@ -41,15 +58,27 @@ def _surface_error(prefix: str, resp: httpx.Response) -> RuntimeError:
if not isinstance(node_err, dict):
continue
errors = node_err.get("errors")
node = f"{node_id}({node_err['class_type']})" if node_err.get("class_type") else node_id
if isinstance(errors, list):
for e in errors:
if isinstance(e, dict):
msg = e.get("message") or e.get("details")
if msg:
parts.append(f"node {node_id}: {msg}")
detail = "; ".join(parts)
suffix = f": {detail}" if detail else ""
return RuntimeError(f"{prefix} ({resp.status_code}){suffix}")
if not isinstance(e, dict):
continue
if e.get("type") == "value_not_in_list":
extra = e.get("extra_info") or {}
parts.append(
f"节点 {node}:ComfyUI 上没有「{extra.get('received_value', '?')}」"
f"({extra.get('input_name', '参数')} 不在可选列表中,"
"多为模型文件未安装)"
)
continue
msg = e.get("message") or e.get("details")
if msg and e.get("details") and e["details"] != msg:
msg = f"{msg}({e['details']})"
if msg:
parts.append(f"节点 {node}:{msg}")
detail = ";".join(parts)
suffix = f":{detail}" if detail else ""
return RuntimeError(f"{prefix}(HTTP {resp.status_code}){suffix}")
class ComfyUIClient:
@@ -64,18 +93,47 @@ class ComfyUIClient:
async def health(self) -> bool:
try:
resp = await self._http.get(f"{self.base_url}/system_stats")
resp = await self._http.get(
f"{self.base_url}/system_stats", timeout=HEALTH_TIMEOUT_SEC
)
return resp.status_code == 200
except Exception:
return False
def _cache_key(self) -> str:
auth = self._headers.get("Authorization") or next(
(v for k, v in self._headers.items() if k.lower() not in ("authorization",)), ""
)
return f"{self.base_url}|{auth}"
async def object_info(self, force_refresh: bool = False) -> dict[str, Any]:
"""The server's full ``/object_info`` node catalogue, cached per endpoint.
Cached for OBJECT_INFO_TTL_SEC; ``force_refresh`` re-fetches (the
connections page "重新检查" button after the user installs nodes).
Raises on transport/HTTP errors so callers can report honestly.
"""
key = self._cache_key()
now = time.monotonic()
if not force_refresh:
hit = _object_info_cache.get(key)
if hit and now - hit[0] < OBJECT_INFO_TTL_SEC:
return hit[1]
resp = await self._http.get(f"{self.base_url}/object_info", timeout=30.0)
resp.raise_for_status()
data = resp.json()
if not isinstance(data, dict):
raise RuntimeError("ComfyUI 返回的 /object_info 不是对象")
_object_info_cache[key] = (now, data)
return data
async def upload_image(self, path: Path, subfolder: str = "calliope") -> str:
data = path.read_bytes()
files = {"image": (path.name, data, "application/octet-stream")}
form = {"overwrite": "true", "subfolder": subfolder}
resp = await self._http.post(f"{self.base_url}/upload/image", files=files, data=form)
if resp.status_code >= 400:
raise _surface_error("ComfyUI image upload failed", resp)
raise _surface_error("图片上传到 ComfyUI 失败", resp)
result = resp.json()
name = result.get("name", path.name)
sub = result.get("subfolder") or subfolder
@@ -95,7 +153,7 @@ class ComfyUIClient:
form["subfolder"] = subfolder
resp = await self._http.post(f"{self.base_url}/upload/image", files=files, data=form)
if resp.status_code >= 400:
raise _surface_error("ComfyUI audio upload failed", resp)
raise _surface_error("音频上传到 ComfyUI 失败", resp)
result = resp.json()
name = result.get("name", path.name)
sub = result.get("subfolder") or subfolder
@@ -108,7 +166,7 @@ class ComfyUIClient:
form = {"overwrite": "true", "subfolder": subfolder, "type": "input"}
resp = await self._http.post(f"{self.base_url}/upload/image", files=files, data=form)
if resp.status_code >= 400:
raise _surface_error("ComfyUI video upload failed", resp)
raise _surface_error("视频上传到 ComfyUI 失败", resp)
result = resp.json()
name = result.get("name", path.name)
sub = result.get("subfolder") or subfolder
@@ -202,11 +260,13 @@ class ComfyUIClient:
payload = {"prompt": workflow, "client_id": self.client_id}
resp = await self._http.post(f"{self.base_url}/prompt", json=payload)
if resp.status_code >= 400:
raise _surface_error("ComfyUI rejected the workflow", resp)
raise _surface_error(
"ComfyUI 拒绝执行该工作流(常见原因:缺少模型/自定义节点,或参数不合法)", resp
)
data = resp.json()
prompt_id = data.get("prompt_id")
if not prompt_id:
raise RuntimeError(f"ComfyUI /prompt missing prompt_id: {data}")
raise RuntimeError(f"ComfyUI 返回异常:/prompt 响应缺少 prompt_id({str(data)[:200]})")
return prompt_id
async def get_history(self, prompt_id: str) -> dict[str, Any] | None:
@@ -215,6 +275,37 @@ class ComfyUIClient:
data = resp.json()
return data.get(prompt_id)
async def locate_prompt(self, prompt_id: str) -> tuple[str, int]:
"""Where ``prompt_id`` sits in ComfyUI's ``/queue``.
Returns ``("running", 0)``, ``("pending", <prompts ahead>)``,
``("missing", 0)`` when the queue does not list it, or
``("unknown", 0)`` when this server has no ``/queue`` route.
"""
resp = await self._http.get(f"{self.base_url}/queue")
if resp.status_code == 404:
return "unknown", 0
resp.raise_for_status()
data = resp.json() if resp.content else {}
running = data.get("queue_running") or []
pending = data.get("queue_pending") or []
def pid(item: Any) -> Any:
return item[1] if isinstance(item, list) and len(item) > 1 else None
if any(pid(item) == prompt_id for item in running):
return "running", 0
mine = next((item for item in pending if pid(item) == prompt_id), None)
if mine is None:
return "missing", 0
ahead = sum(
1
for item in pending
if isinstance(item, list) and item and isinstance(item[0], (int, float))
and isinstance(mine[0], (int, float)) and item[0] < mine[0]
)
return "pending", len(running) + ahead
async def download_image(
self,
filename: str,
@@ -224,7 +315,10 @@ class ComfyUIClient:
) -> Path:
params = {"filename": filename, "subfolder": subfolder, "type": folder_type}
resp = await self._http.get(f"{self.base_url}/view", params=params)
resp.raise_for_status()
if resp.status_code >= 400:
raise _surface_error(f"从 ComfyUI 下载结果文件 {filename} 失败", resp)
if not resp.content:
raise RuntimeError(f"从 ComfyUI 下载的结果文件 {filename} 是空文件")
if dest is None:
dest = settings.assets_dir / "comfy_downloads" / filename
dest.parent.mkdir(parents=True, exist_ok=True)
@@ -237,16 +331,46 @@ class ComfyUIClient:
except Exception as exc:
logger.warning("ComfyUI interrupt failed: %s", exc)
def extract_outputs(self, history: dict[str, Any]) -> list[dict[str, str]]:
# History items carry no explicit media type — infer from the file
# extension first, then the history key the node reported under.
_VIDEO_EXTS = frozenset({".mp4", ".webm", ".mkv", ".mov", ".gif", ".m4v", ".avi", ".flv"})
_IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff"})
@classmethod
def _infer_media_kind(cls, filename: str, history_key: str) -> str | None:
ext = Path(str(filename)).suffix.lower()
if ext in cls._VIDEO_EXTS:
return "video"
if ext in cls._IMAGE_EXTS:
return "image"
if history_key in ("gifs", "videos"):
return "video"
return None
def extract_outputs(
self, history: dict[str, Any], node_kinds: dict[str, str] | None = None
) -> list[dict[str, str]]:
"""Media items in a finished prompt's history.
``node_kinds`` (HEL-638) scopes the harvest to the workflow's
confirmed output nodes — {nodeId: image|video} — so results from
nodes the user never designated are ignored, and each item carries
the node id plus its inferred media kind for mismatch checks.
"""
outputs: list[dict[str, str]] = []
for _node_id, node_out in (history.get("outputs") or {}).items():
for node_id, node_out in (history.get("outputs") or {}).items():
if node_kinds is not None and str(node_id) not in node_kinds:
continue
for key in ("images", "gifs", "videos"):
for item in node_out.get(key) or []:
filename = item.get("filename", "")
outputs.append(
{
"filename": item.get("filename", ""),
"filename": filename,
"subfolder": item.get("subfolder", ""),
"type": item.get("type", "output"),
"node_id": str(node_id),
"media_kind": self._infer_media_kind(filename, key) or "",
}
)
return outputs
@@ -0,0 +1,126 @@
"""Structural checks on an imported ComfyUI API workflow.
``error`` findings mean ComfyUI will reject the prompt as exported, so the
studio blocks generation; ``warning`` findings only explain odd parameters.
Nothing here repairs a workflow — a broken export has to be re-exported from
a ComfyUI that has the missing custom nodes installed.
"""
from __future__ import annotations
from typing import Any
REEXPORT_HINT = "请在已安装对应自定义节点的 ComfyUI 中打开该工作流,重新导出 API 格式 JSON 后再导入"
# Nodes that cannot run without a model link.
_NEEDS_MODEL = frozenset(
{"KSampler", "KSamplerAdvanced", "BasicGuider", "CFGGuider", "BasicScheduler"}
)
def _title(node_id: str, node: dict[str, Any]) -> str:
title = str((node.get("_meta") or {}).get("title") or "").strip()
return f"节点 {node_id}「{title}」" if title else f"节点 {node_id}"
def workflow_warnings(workflow: dict[str, Any]) -> list[dict[str, str]]:
if not isinstance(workflow, dict):
return []
found: list[dict[str, str]] = []
nodes = {str(k): v for k, v in workflow.items() if isinstance(v, dict)}
untyped = [nid for nid, node in nodes.items() if not str(node.get("class_type") or "").strip()]
for nid in untyped:
found.append(
{
"level": "error",
"code": "missing_class_type",
"node_id": nid,
"message": f"{_title(nid, nodes[nid])}缺少节点类型(class_type),"
"导出时 ComfyUI 未加载该自定义节点",
}
)
consumed: set[str] = set()
for nid, node in nodes.items():
inputs = node.get("inputs") or {}
for name, val in inputs.items():
if isinstance(val, list) and len(val) == 2 and isinstance(val[0], (str, int)):
src = str(val[0])
consumed.add(src)
if src not in nodes:
found.append(
{
"level": "error",
"code": "dangling_link",
"node_id": nid,
"message": f"{_title(nid, node)}的输入「{name}」"
f"连到了不存在的节点 {src}",
}
)
ct = str(node.get("class_type") or "")
if ct in _NEEDS_MODEL and not isinstance(inputs.get("model"), list):
found.append(
{
"level": "error",
"code": "missing_model_link",
"node_id": nid,
"message": f"{_title(nid, node)}({ct})没有连接模型(model),"
"模型加载链路在导出时丢失",
}
)
for nid, node in nodes.items():
ct = str(node.get("class_type") or "")
if ct.startswith("Empty") and "Latent" in ct and nid not in consumed:
found.append(
{
"level": "warning",
"code": "orphan_latent",
"node_id": nid,
"message": f"{_title(nid, nodes[nid])}没有接到任何节点,"
"它的宽高不会生效,已不在表单中显示",
}
)
# HEL-636/638: a workflow without any recognizable output node can never
# return a result — block it with a readable reason instead of letting
# ComfyUI run blind. Unknown candidates (needs_mapping) don't trigger
# this: the user designates them on the import/connections page, and
# until then the row stays disabled.
from calliope.comfyui.parser import detect_output_nodes
outputs = detect_output_nodes(workflow)
if not any(o.get("kind") in ("image", "video") for o in outputs):
unknown = [o for o in outputs if o.get("evidence") == "unknown_node"]
if not unknown:
found.append(
{
"level": "error",
"code": "no_output",
"node_id": "",
"message": "未在图中找到输出节点(SaveImage / 视频合成等),"
"请确认导出的是 API 格式且包含保存节点",
}
)
else:
names = "、".join(f"节点 {o['nodeId']}" for o in unknown[:4])
found.append(
{
"level": "warning",
"code": "unknown_output_node",
"node_id": names,
"message": f"存在无法识别的输出节点({names}),"
"需人工指定实际输出节点及其媒体类型(图片/视频)后才能生成",
}
)
return found
def blocking_problem(workflow: dict[str, Any]) -> str | None:
"""One Chinese sentence explaining why this workflow cannot be submitted."""
errors = [w["message"] for w in workflow_warnings(workflow) if w["level"] == "error"]
if not errors:
return None
shown = ";".join(errors[:4]) + (f";等 {len(errors)} 处" if len(errors) > 4 else "")
return f"该工作流文件不完整,ComfyUI 无法执行:{shown}。{REEXPORT_HINT}。"
@@ -0,0 +1,160 @@
"""Suggested workflow metadata for imports (HEL-681 / S2 data layer).
Everything here derives from two honest sources: the repo directory the file
came from (category) and the converted API graph itself (usage, media in/out,
model family, note text). Per HEL-675's inventory, clean display names and
tags stay human work — the suggestion is a starting point, never a guess that
hides uncertainty.
"""
from __future__ import annotations
import re
from typing import Any
from calliope.comfyui.parser import (
classify_usage,
parse_dynamic_inputs,
parse_dynamic_outputs,
)
# Repo top-level directory → (category key, display label). Directory names
# are mapping sources only — they never surface to users verbatim.
CATEGORY_DIRS: dict[str, tuple[str, str]] = {
"0-基础处理:图像合并-音频分离-视频截取合并-图像视频放大": ("basic", "基础处理"),
"1-语音生成": ("audio_tts", "语音生成"),
"2-音乐生成": ("music", "音乐生成"),
"3-图像生成": ("image", "图像生成"),
"4-视频生成": ("video", "视频生成"),
"5-数字人": ("digital_human", "数字人"),
"6-动作迁移": ("motion_transfer", "动作迁移"),
}
# Evidence patterns → model family. Shared VAE/text-encoder files must NOT
# count (HEL-675: Anima/Z-Image graphs reference qwen_image_vae and get
# mislabeled Qwen — `qwen.?image(?!_vae)` is deliberate).
_FAMILY_PATTERNS: tuple[tuple[str, str], ...] = (
(r"minimax[_\-]?(music|_audio)|MiniMaxMusic", "MiniMax-Music"),
(r"minimax.?h3|minimax_h3|h3[-_]realism|fl2v[aa]?", "MiniMax-H3"),
(r"ltx[-_.]?2", "LTX-2.5"),
(r"ltx", "LTX"),
(r"krea", "Krea2"),
(r"qwen.?image(?!_vae)|qwen_image_2|qwen2vl|qwen3vl|Qwen3?[-.]?ASR|qwen3?[-_.]?tts", "Qwen"),
(r"z[._-]?image", "Z-Image"),
(r"flux", "Flux"),
(r"\bwan", "Wan"),
(r"ace[_-]?step", "ACE-Step"),
(r"index[_-]?tts", "IndexTTS2"),
(r"anima[-_.]?(turbo|klein)", "Anima"),
(r"sdpose", "SDPose"),
(r"gemma", "Gemma"),
(r"instantmesh|instant_mesh", "InstantMesh"),
(r"hidream", "HiDream"),
)
_FAMILY_RE = [(re.compile(pat, re.IGNORECASE), fam) for pat, fam in _FAMILY_PATTERNS]
# Main-model loaders dominate the vote: a UNET/checkpoint reference says far
# more about the workflow's family than a shared VAE or text encoder does.
_PRIMARY_LOADER_RE = re.compile(r"UNETLoader|CheckpointLoader|unet|diffusion_model", re.IGNORECASE)
# Directory names that carry family evidence the files' own widget values
# lack (HEL-675: 14 krea2 files reference no "krea" string at all).
_FAMILY_DIRS = (
(re.compile(r"krea2", re.IGNORECASE), "Krea2"),
(re.compile(r"Minimax-h3|uncensored", re.IGNORECASE), "MiniMax-H3"),
(re.compile(r"Z-Image", re.IGNORECASE), "Z-Image"),
(re.compile(r"Qwen-Image", re.IGNORECASE), "Qwen"),
(re.compile(r"^Anima$", re.IGNORECASE), "Anima"),
(re.compile(r"Flux", re.IGNORECASE), "Flux"),
)
_NOTE_TYPES = frozenset({"Note", "MarkdownNote"})
def category_from_source_path(source_path: str | None) -> tuple[str | None, str | None]:
"""(category key, label) derived from the file's repo-relative path."""
if not source_path:
return None, None
head = source_path.replace("\\", "/").split("/", 1)[0]
return CATEGORY_DIRS.get(head, (None, None))
def model_family_from(workflow: dict[str, Any], source_path: str | None = None) -> str | None:
"""The graph's dominant model family: main-model loader references weigh
triple, other node evidence single, and the repo sub-directory adds a
strong vote for families the widget values don't spell out."""
votes: dict[str, int] = {}
for node in workflow.values():
if not isinstance(node, dict):
continue
class_type = str(node.get("class_type") or "")
blob = " ".join(
[class_type]
+ [str(v) for v in (node.get("inputs") or {}).values() if isinstance(v, str)]
)
weight = 3 if _PRIMARY_LOADER_RE.search(blob) else 1
for pattern, family in _FAMILY_RE:
if pattern.search(blob):
votes[family] = votes.get(family, 0) + weight
break # one vote per node: strongest family that node shows
if source_path:
parts = source_path.replace("\\", "/").split("/")
for part in parts[1:2]:
for pattern, family in _FAMILY_DIRS:
if pattern.search(part):
votes[family] = votes.get(family, 0) + 2
if not votes:
return None
return max(votes.items(), key=lambda kv: kv[1])[0]
def note_description(ui_workflow: dict[str, Any], limit: int = 160) -> str | None:
"""First non-trivial author note as description material (never final copy)."""
for node in ui_workflow.get("nodes") or []:
if not isinstance(node, dict) or str(node.get("type")) not in _NOTE_TYPES:
continue
values = node.get("widgets_values")
text = values[0] if isinstance(values, list) and values else None
if isinstance(values, dict):
text = values.get("text") or next(iter(values.values()), None)
if isinstance(text, str):
text = text.strip()
if len(text) >= 8:
return text[:limit].strip()
return None
def suggested_metadata(
converted: dict[str, Any],
source_path: str | None = None,
ui_workflow: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""The import suggestion bundle for one converted workflow."""
judgment = classify_usage(converted)
media_kinds = ("image", "image_list", "video", "video_list", "audio", "audio_list")
media_in = sorted(
{
entry["kind"].replace("_list", "")
for entry in parse_dynamic_inputs(converted)
if entry.get("kind") in media_kinds
}
)
media_out = sorted(
{
str(entry.get("kind"))
for entry in parse_dynamic_outputs(converted)
if entry.get("kind") in ("image", "video", "audio")
}
)
category, category_label = category_from_source_path(source_path)
return {
"kind": "video" if "video" in media_out and "image" not in media_out else "image",
"usage": judgment["usage"],
"usage_ambiguous": judgment["ambiguous"],
"category": category,
"category_label": category_label,
"model_family": model_family_from(converted, source_path),
"media_in": media_in,
"media_out": media_out,
"description": note_description(ui_workflow or {}),
}
@@ -0,0 +1,148 @@
"""Stored output-node mapping for imported workflows (HEL-638).
Unknown custom output nodes are never auto-typed. The import confirm page
(or the connections table) stores the user's explicit designation — which
node ids are the real outputs and each one's media type — in
``workflows.output_map`` with ``output_map_source='user'``. Everything that
re-derives schemas (reanalyze, migration, plain field edits) must keep that
decision; only a fresh explicit payload replaces it.
Rows without unknown candidates carry an ``auto`` map derived from the
trusted registry/tagged outputs so the generation path always knows which
history nodes to harvest.
"""
from __future__ import annotations
import json
from typing import Any
from calliope.comfyui.parser import parse_dynamic_outputs
from calliope.comfyui.registry import (
IMAGE_OUTPUT_CLASSES,
NUMBER_CLASSES,
TEXT_AREA_CLASSES,
VIDEO_OUTPUT_CLASSES,
)
VALID_OUTPUT_KINDS = ("image", "video")
def _wiring_class(class_type: str) -> bool:
"""Parameter / wiring classes can never be designated as outputs."""
return (
not class_type
or class_type in TEXT_AREA_CLASSES
or class_type in NUMBER_CLASSES
or class_type.startswith("Empty")
or class_type.startswith("Primitive")
)
def _entry_public(entry: dict[str, Any]) -> dict[str, Any]:
out = {
"nodeId": str(entry.get("nodeId") or ""),
"kind": entry.get("kind"),
}
if entry.get("label"):
out["label"] = entry["label"]
if entry.get("class_type"):
out["class_type"] = entry["class_type"]
return out
def auto_output_map(workflow: dict[str, Any]) -> list[dict[str, Any]]:
"""Trusted outputs only (tagged titles / registry classes)."""
if not isinstance(workflow, dict):
return []
return [
_entry_public(o)
for o in parse_dynamic_outputs(workflow)
if o.get("kind") in VALID_OUTPUT_KINDS
]
def needs_user_output_map(workflow: dict[str, Any]) -> bool:
"""True when the graph has unknown output candidates (HEL-638)."""
if not isinstance(workflow, dict):
return False
return any(
o.get("evidence") == "unknown_node" for o in parse_dynamic_outputs(workflow)
)
def validate_output_map(
workflow: dict[str, Any], entries: Any
) -> list[str]:
"""Chinese problems with a user-supplied output map; empty = valid."""
if not isinstance(entries, list) or not entries:
return ["输出节点映射不能为空:请至少指定一个输出节点及其媒体类型"]
seen: set[str] = set()
problems: list[str] = []
for entry in entries:
if not isinstance(entry, dict):
problems.append("输出节点映射格式有误(应为对象数组)")
continue
nid = str(entry.get("nodeId") or "")
node = workflow.get(nid) if isinstance(workflow, dict) else None
if not nid or not isinstance(node, dict):
problems.append(f"输出节点 {nid or '?'} 不存在于该工作流中")
continue
ct = str(node.get("class_type") or "").strip()
if not ct:
problems.append(f"节点 {nid} 缺少节点类型,不能作为输出节点")
elif _wiring_class(ct) and ct not in (IMAGE_OUTPUT_CLASSES | VIDEO_OUTPUT_CLASSES):
problems.append(f"节点 {nid}({ct})是参数/连线节点,不能作为输出节点")
if entry.get("kind") not in VALID_OUTPUT_KINDS:
problems.append(f"输出节点 {nid} 的媒体类型必须是图片或视频")
if nid in seen:
problems.append(f"输出节点 {nid} 重复指定")
seen.add(nid)
return problems
def load_output_map(raw: str | None) -> list[dict[str, Any]]:
if not raw:
return []
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return []
return data if isinstance(data, list) else []
def resolve_output_map(
output_map_raw: str | None, source: str | None, workflow: dict[str, Any]
) -> tuple[list[dict[str, Any]], str | None]:
"""The map generation harvests, plus whose decision it is.
A user map wins as long as its node ids still exist in the stored JSON
(defensive pruning — PATCH cannot edit the JSON, but keep it safe);
otherwise the trusted auto map applies. Empty list back means "not
confirmed yet".
"""
if source == "user":
entries = [
e for e in load_output_map(output_map_raw) if str(e.get("nodeId") or "") in
(workflow if isinstance(workflow, dict) else {})
]
if entries:
return entries, "user"
return [], None
auto = auto_output_map(workflow)
return (auto, "auto") if auto else ([], None)
def mapping_problem(
workflow: dict[str, Any], output_map_raw: str | None, source: str | None
) -> str | None:
"""Chinese reason this workflow's outputs are not confirmed, else None."""
if not needs_user_output_map(workflow):
return None
entries, resolved = resolve_output_map(output_map_raw, source, workflow)
if resolved == "user" and entries:
return None
return (
"该工作流含无法识别的自定义输出节点,需在「ComfyUI 连接」页指定"
"实际输出节点及其媒体类型(图片/视频)后才能启用或生成"
)
File diff suppressed because it is too large Load Diff
@@ -1,10 +1,48 @@
"""Patch user values into ComfyUI API-format workflow by nodeId."""
from __future__ import annotations
import copy
from typing import Any
from calliope.comfyui.registry import class_to_patch_field
from calliope.comfyui.registry import (
AUDIO_CLASSES,
IMAGE_CLASSES,
VIDEO_CLASSES,
VIDEO_FILE_CLASSES,
class_to_patch_field,
optional_frame_sibling,
)
# Input keys of the form "<nodeId>@<widget>" target one named widget on a
# node (auto-detected inputs: KSampler seed/steps/cfg, latent width/height…).
FIELD_KEY_SEP = "@"
# Media-loader families and the Chinese slot label used on cloned nodes.
_GROUP_LABELS: tuple[tuple[frozenset[str], str], ...] = (
(IMAGE_CLASSES, "参考图"),
(VIDEO_CLASSES, "参考视频"),
(AUDIO_CLASSES, "参考音频"),
)
def _loader_label(class_type: str) -> str:
for classes, label in _GROUP_LABELS:
if class_type in classes:
return label
return "参考素材"
def _loader_field(class_type: str, inputs: dict[str, Any]) -> str:
"""The widget a loader node of this class stores its file value in."""
if class_type in IMAGE_CLASSES:
return "image"
if class_type in VIDEO_CLASSES:
return "file" if class_type in VIDEO_FILE_CLASSES else "video"
if class_type in AUDIO_CLASSES:
# VHS_LoadAudio names its widget "audio:" (with the colon).
return "audio" if "audio" in inputs else "audio:"
return "image"
def _resolve_field(field: str, inputs: dict[str, Any]) -> str:
@@ -26,6 +64,105 @@ def _resolve_field(field: str, inputs: dict[str, Any]) -> str:
return field
def resolve_patch_field(node: dict[str, Any]) -> str:
"""The widget a plain nodeId key writes to on this node."""
return _resolve_field(
class_to_patch_field(node.get("class_type", "")), dict(node.get("inputs") or {})
)
def _next_node_id(workflow: dict[str, Any]) -> str:
numeric = [int(k) for k in workflow if str(k).isdigit()]
return str(max(numeric, default=0) + 1)
def apply_media_group(
workflow: dict[str, Any], node_id: str, group: str, paths: list[str]
) -> None:
"""Rewire an Autogrow media group (image/video/audio) to exactly ``paths``.
``<group>.<prefix>0 … N-1`` each get their own loader node: the
workflow's existing ones are reused in slot order, extra ones are clones
of the first, and slots/loaders left over from the export are removed so
no stale media reaches ComfyUI. File values are local paths —
``prepare_media_inputs`` uploads them right before queueing.
"""
from calliope.comfyui.parser import image_group_prefix, image_group_slots
node = workflow.get(node_id)
if not isinstance(node, dict):
return
prefix = image_group_prefix(node, group)
if prefix is None:
return
inputs = dict(node.get("inputs") or {})
slots = image_group_slots(node, group, prefix)
loaders: list[str] = []
for _n, name, link in slots:
inputs.pop(name, None)
if isinstance(link, list) and len(link) == 2:
src = workflow.get(str(link[0]))
if isinstance(src, dict) and str(src.get("class_type") or "") in (
IMAGE_CLASSES | VIDEO_CLASSES | AUDIO_CLASSES
):
loaders.append(str(link[0]))
if not loaders:
return
template = copy.deepcopy(workflow[loaders[0]])
label = _loader_label(str(template.get("class_type") or ""))
still_used: set[str] = set()
for nid, other in workflow.items():
if nid == node_id or not isinstance(other, dict):
continue
for val in (other.get("inputs") or {}).values():
if isinstance(val, list) and len(val) == 2:
still_used.add(str(val[0]))
for nid in loaders[len(paths):]:
if nid not in still_used and nid not in loaders[: len(paths)]:
workflow.pop(nid, None)
for i, path in enumerate(paths):
if i < len(loaders):
nid = loaders[i]
loader = workflow[nid]
else:
nid = _next_node_id(workflow)
loader = copy.deepcopy(template)
loader_inputs = dict(loader.get("inputs") or {})
field = _loader_field(str(loader.get("class_type") or ""), loader_inputs)
loader_inputs[field] = path
loader["inputs"] = loader_inputs
loader["_meta"] = {**(loader.get("_meta") or {}), "title": f"{label} {i + 1}"}
workflow[nid] = loader
inputs[f"{group}.{prefix}{i}"] = [nid, 0]
node["inputs"] = inputs
# Original name kept: image groups were the first (and most common) family.
apply_image_group = apply_media_group
def apply_optional_frame(
workflow: dict[str, Any], node_id: str, field: str, sibling: str, path: str
) -> None:
"""Wire ``path`` into an unwired optional frame input (H3 ``last_frame``)
through a new LoadImage cloned from the ``sibling`` frame's loader."""
node = workflow[node_id]
inputs = dict(node.get("inputs") or {})
src = workflow.get(str(inputs[sibling][0]))
if isinstance(src, dict) and str(src.get("class_type") or "") in IMAGE_CLASSES:
loader = copy.deepcopy(src)
else:
loader = {"class_type": "LoadImage", "inputs": {}}
loader["inputs"] = {**(loader.get("inputs") or {}), "image": path}
loader["_meta"] = {"title": "尾帧" if field == "last_frame" else field}
nid = _next_node_id(workflow)
workflow[nid] = loader
inputs[field] = [nid, 0]
node["inputs"] = inputs
def patch_workflow(
base: dict[str, Any],
values_by_node_id: dict[str, Any],
@@ -35,11 +172,31 @@ def patch_workflow(
if value is None:
continue
key = str(node_id)
field: str | None = None
if key not in patched and FIELD_KEY_SEP in key:
key, field = key.rsplit(FIELD_KEY_SEP, 1)
node = patched.get(key)
if not isinstance(node, dict):
continue
inputs = dict(node.get("inputs") or {})
field = _resolve_field(class_to_patch_field(node.get("class_type", "")), inputs)
if field is not None and field not in inputs and any(
k.startswith(f"{field}.") for k in inputs
):
paths = [value] if isinstance(value, str) else value
if isinstance(paths, list):
clean = [p for p in paths if isinstance(p, str) and p.strip()]
if clean:
apply_media_group(patched, key, field, clean)
continue
if field is not None and (sibling := optional_frame_sibling(node, field)):
if isinstance(value, str) and value.strip():
apply_optional_frame(patched, key, field, sibling, value)
continue
if field is None:
field = resolve_patch_field(node)
elif isinstance(inputs.get(field), list):
# Linked input ([src_id, slot]) — never overwrite the wiring.
continue
inputs[field] = value
node["inputs"] = inputs
patched[key] = node
@@ -0,0 +1,137 @@
"""L2 connection preflight: check a workflow against a live /object_info (HEL-642/643).
Layer 1 (lint.py) judges the JSON on its own; this module judges the JSON
against what the target ComfyUI actually has installed. Two generic rules,
zero hard-coding for any specific workflow or model name:
1. Node existence — every ``class_type`` in the JSON must exist as a key of
the server's /object_info catalogue; missing ones are custom nodes the
user has not installed.
2. Combo value legality — for every combo input the server declares (an
input whose spec carries a candidate list — unet_name/clip_name/
sampler/scheduler/…), the JSON's current widget value must be one of the
candidates; a miss usually means a model file or component is missing.
File-upload widgets (LoadImage.image and friends) are skipped: their combo
lists are the server's input directory at export time and the value is
replaced by an upload before every submit, so a miss there says nothing.
"""
from __future__ import annotations
from typing import Any
from calliope.comfyui.registry import AUDIO_CLASSES, IMAGE_CLASSES, VIDEO_CLASSES
# Media-loader classes and the widget names that hold their file values —
# upload-backed combos that cannot be judged before an upload happens.
_MEDIA_LOADER_FIELDS: tuple[frozenset[str], frozenset[str]] = (
(IMAGE_CLASSES, frozenset({"image"})),
(AUDIO_CLASSES, frozenset({"audio", "audio:"})),
(VIDEO_CLASSES, frozenset({"file", "video"})),
)
# object_info widget-config keys that mark an input as file-upload backed.
_UPLOAD_CONFIG_KEYS = ("image_upload", "audio_upload", "video_upload", "upload")
_MAX_CANDIDATES_SHOWN = 6
def _node_title(node: dict[str, Any]) -> str:
title = (node.get("_meta") or {}).get("title")
return str(title).strip() if title else ""
def _is_upload_backed(class_type: str, input_name: str, config: Any) -> bool:
if isinstance(config, dict) and any(config.get(k) for k in _UPLOAD_CONFIG_KEYS):
return True
for classes, fields in _MEDIA_LOADER_FIELDS:
if class_type in classes and input_name in fields:
return True
return False
def _combo_candidates(spec: Any) -> list[str] | None:
"""The candidate list when ``spec`` is a combo input, else None.
A combo spec is ``[["a", "b"], {config}]`` — first element a list of
plain strings. Typed inputs (``["FLOAT", {…}]``) return None.
"""
if not (isinstance(spec, list) and len(spec) >= 1):
return None
values = spec[0]
if not isinstance(values, list) or not values:
return None
if not all(isinstance(v, str) for v in values):
return None
return values
def check_workflow_against(
object_info: dict[str, Any], workflow: dict[str, Any]
) -> list[dict[str, Any]]:
"""Findings for every problem the target server's catalogue reveals.
Each finding is ``{level, code, node_id, node_title, message, hint}``
(``level`` is always ``"error"`` for now — both rules are hard misses).
"""
findings: list[dict[str, Any]] = []
if not isinstance(object_info, dict) or not isinstance(workflow, dict):
return findings
for node_id, node in workflow.items():
if not isinstance(node, dict):
continue
class_type = str(node.get("class_type") or "")
title = _node_title(node)
label = f"节点 {node_id}「{title}」" if title else f"节点 {node_id}"
spec = object_info.get(class_type)
if not isinstance(spec, dict):
findings.append(
{
"level": "error",
"code": "missing_node",
"node_id": str(node_id),
"node_title": title,
"message": (
f"缺少自定义节点 {class_type or '(未知类名)'}({label})——"
"该 ComfyUI 未安装此节点,需先安装对应的节点包"
),
"hint": "安装节点包后回到本页点「重新检查」即可复查,无需重新导入。",
}
)
continue
declared = (spec.get("input") or {}) if isinstance(spec.get("input"), dict) else {}
inputs = node.get("inputs") or {}
for section in ("required", "optional"):
entries = declared.get(section)
if not isinstance(entries, dict):
continue
for input_name, in_spec in entries.items():
value = inputs.get(input_name)
# Wired inputs (links) and absent widgets are not combos to judge.
if isinstance(value, (list, dict)) or value is None:
continue
config = in_spec[1] if isinstance(in_spec, list) and len(in_spec) > 1 else None
if _is_upload_backed(class_type, str(input_name), config):
continue
candidates = _combo_candidates(in_spec)
if candidates is None:
continue
if value in candidates:
continue
shown = "、".join(candidates[:_MAX_CANDIDATES_SHOWN])
if len(candidates) > _MAX_CANDIDATES_SHOWN:
shown += f" 等 {len(candidates)} 项"
findings.append(
{
"level": "error",
"code": "value_not_in_list",
"node_id": str(node_id),
"node_title": title,
"message": (
f"{label}:值「{value}」不在 ComfyUI 的可选列表"
f"(参数 {input_name})——多为对应的模型文件或组件未安装"
),
"hint": f"该 ComfyUI 上可选:{shown}" if shown else None,
}
)
return findings
@@ -1,7 +1,7 @@
"""Unified class_type → kind + patch-field registry (MuseAgent port)."""
from __future__ import annotations
from typing import Literal
from typing import Any, Literal
ComfyInputKind = Literal["text", "textarea", "number", "image", "image_url", "audio", "video"]
ComfyOutputKind = Literal["image", "video", "other"]
@@ -20,7 +20,7 @@ TEXT_AREA_CLASSES = frozenset(
NUMBER_CLASSES = frozenset(
{"INT", "FLOAT", "PrimitiveInt", "PrimitiveFloat", "KSampler", "KSamplerAdvanced"}
)
IMAGE_CLASSES = frozenset({"LoadImage", "ImageLoader", "ETN_LoadImageBase64"})
IMAGE_CLASSES = frozenset({"LoadImage", "LoadImageMask", "ImageLoader", "ETN_LoadImageBase64"})
IMAGE_URL_CLASSES = frozenset({"Load Image From Url (mtb)"})
AUDIO_CLASSES = frozenset({"LoadAudio", "VHS_LoadAudio"})
# Stock LoadVideo uses the `file` widget; VHS uses `video`.
@@ -30,8 +30,27 @@ VIDEO_OUTPUT_CLASSES = frozenset(
{"VHS_VideoCombine", "SaveVideo", "VideoOutput", "AnimateDiffCombine"}
)
IMAGE_OUTPUT_CLASSES = frozenset(
{"SaveImage", "PreviewImage", "SaveImageWebsocket", "ETN_SendImageWebSocket"}
{"SaveImage", "PreviewImage", "SaveImageWebsocket", "ETN_SendImageWebSocket",
"SaveImageAdvanced"}
)
# Frame-conditioned video nodes and the optional frame input an export may
# leave unwired. MiniMax H3's FL2VA node takes `first_frame` plus an
# optional `last_frame`; the studio offers the slot and the patcher adds
# the LoadImage only when the user supplies that frame.
OPTIONAL_FRAME_INPUTS: dict[str, dict[str, str]] = {
"MiniMaxH3ImageToVideo": {"last_frame": "first_frame"},
}
def optional_frame_sibling(node: dict[str, Any], field: str) -> str | None:
"""The wired frame input an unwired optional ``field`` mirrors
(``last_frame`` → ``first_frame``); None when it does not apply."""
inputs = node.get("inputs") or {}
sibling = OPTIONAL_FRAME_INPUTS.get(str(node.get("class_type") or ""), {}).get(field)
if sibling is None or field in inputs:
return None
link = inputs.get(sibling)
return sibling if isinstance(link, list) and len(link) == 2 else None
def class_to_input_kind(class_type: str) -> ComfyInputKind:
@@ -30,6 +30,27 @@ OUTPUT_ROLE_ALIASES: dict[str, frozenset[str]] = {
"video": frozenset({"video", "vid"}),
}
# The five workflow usages the studio distinguishes (HEL-636). Derived from
# graph evidence only (output media + consumed image inputs) — never from
# file names, model names or fixed node ids.
WorkflowUsage = str
USAGE_VALUES: tuple[str, ...] = (
"text2image",
"image2image",
"text2video",
"image2video",
"ref2video",
)
def usage_to_kind(usage: str | None) -> str | None:
"""Output media kind of a usage (image / video); None when unknown."""
if usage in ("text2image", "image2image"):
return "image"
if usage in ("text2video", "image2video", "ref2video"):
return "video"
return None
def parse_title_tag(title: str) -> tuple[str | None, str | None, str]:
"""Return (kind, role, label) from a Comfy node title.
@@ -13,6 +13,13 @@ def _find_by_role(inputs: list[dict[str, Any]], *roles: str) -> dict[str, Any] |
return None
def _node_sort_key(node_id: object) -> tuple:
"""Numeric order for "12", subgraph "171:154" and field keys "3@seed"."""
return tuple(
(0, int(p)) if p.isdigit() else (1, p) for p in str(node_id).replace("@", ":").split(":")
)
def _find_all_by_role(inputs: list[dict[str, Any]], *roles: str) -> list[dict[str, Any]]:
"""All inputs matching any of the roles, sorted by numeric node id.
@@ -21,7 +28,7 @@ def _find_all_by_role(inputs: list[dict[str, Any]], *roles: str) -> list[dict[st
in this order and it defines ``<Subject N>`` numbering in the prompt.
"""
matches = [inp for inp in inputs if input_has_role(inp, *roles)]
return sorted(matches, key=lambda i: int(i["nodeId"]))
return sorted(matches, key=lambda i: _node_sort_key(i["nodeId"]))
def ref_image_slots(inputs: list[dict[str, Any]]) -> list[dict[str, Any]]:
@@ -151,8 +158,12 @@ def smart_fill_inputs(
break
if ref_images:
for inp, path in zip(ref_image_slots(inputs), ref_images):
values[str(inp["nodeId"])] = path
group = next((inp for inp in inputs if inp.get("kind") == "image_list"), None)
if group is not None:
values[str(group["nodeId"])] = list(ref_images[: group.get("max") or len(ref_images)])
else:
for inp, path in zip(ref_image_slots(inputs), ref_images):
values[str(inp["nodeId"])] = path
if ref_videos:
for inp, path in zip(ref_video_slots(inputs), ref_videos):
@@ -0,0 +1,801 @@
"""UI→API converter for ComfyUI workflow JSON (HEL-681 / S2 data layer).
ComfyUI saves two formats: the editor ("UI") format with ``nodes``/``links``
arrays, and the API ("prompt") format ``{node_id: {class_type, inputs}}`` the
product executes. This module converts UI→API offline and deterministically:
* links are rewritten through ``Reroute`` chains and bypassed (mode 4) nodes;
* packed subgraph instances (frontend ≥1.x ``definitions.subgraphs``) expand
into their inner nodes under qualified ids ``<packed>:<inner>`` — the same
scheme ComfyUI itself exports;
* widget values map through, in priority order: the node's serialized input
list (entries carrying a ``widget`` marker — authoritative, this corpus's
frontend version always writes them), the ``widgets_values_named`` dict,
then a generated registry of widget orders learned from the corpus and the
repo's ground-truth API exports (``ui_widget_orders``).
Anything that cannot be mapped with certainty is reported as an issue and the
file is flagged ``needs_confirmation`` — never silently guessed: a wrong
widget order produces a runnable-looking workflow with swapped seed/steps,
which is worse than an honest「需人工确认」.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from calliope.comfyui.ui_widget_orders import WIDGET_ORDERS
# Values of the frontend-only "control_after_generate" control that serializes
# right after every seed-like INT widget (and is not a real server input).
_CONTROL_MODES = frozenset({"fixed", "increment", "decrement", "randomize"})
# Widget names that are pure UI buttons/previews — never serialized in
# widgets_values (named dicts skip them too) and never API inputs.
_BUTTON_WIDGETS = frozenset(
{"preview", "audioUI", "compare_view", "ToggleableColorPicker", "imagebrowser"}
)
# Leftover positional values that are provably action buttons, not data:
# known labels, self-describing ids, or emoji-prefixed actions. Anything
# else left over keeps the file in the needs-confirmation queue.
_BUTTON_LEFTOVER_LABELS = frozenset({"Edit", "lanpaint_star_button"})
_BUTTON_LEFTOVER_EMOJI = ("🖼", "🎬", "🎵", "➕", "🔁", "🗑", "📂", "🔎", "✨")
def _is_button_leftover(value: Any) -> bool:
if value is None or value == "":
return True
if isinstance(value, str):
return (
value in _BUTTON_LEFTOVER_LABELS
or "button" in value.lower()
or value.startswith(_BUTTON_LEFTOVER_EMOJI)
)
return False
# Virtual editor-only nodes. Links pass *through* reroute-style ones (see
# _resolve_source); the rest are dropped from the graph entirely.
_REROUTE_TYPES = frozenset({"Reroute", "Reroute (rgthree)"})
# Frontend-virtual variable nodes: SetNode captures a value under a name,
# GetNode reads it. Neither reaches the server — links through a Get resolve
# to whatever its same-named Set captured (scope-local).
_SET_TYPES = frozenset({"SetNode"})
_GET_TYPES = frozenset({"GetNode"})
_UI_ONLY_TYPES = frozenset(
{
"Note",
"MarkdownNote",
"Image Comparer (rgthree)",
"GH_ImageVideoComparer",
"Label (rgthree)",
"Fast Groups Bypasser (rgthree)",
"Fast Groups Muter (rgthree)",
"Fast Muter (rgthree)",
"FastGroupsBypassSwitch",
"Mute / Bypass Repeater (rgthree)",
}
)
# Server-side OUTPUT nodes: the closure roots. Learned from the repo's
# ground-truth API exports (their unreferenced classes) + the corpus's
# real save/preview sinks (SaveVideo/SaveImageAdvanced/… custom nodes).
_OUTPUT_CLASSES = frozenset(
{
"SaveImage",
"PreviewImage",
"SaveImageAdvanced",
"SaveImageKJ",
"SaveAnimatedWEBP",
"SaveAnimatedPNG",
"SaveAnimatedGIF",
"SaveVideo",
"SaveAudio",
"SaveAudioMP3",
"SaveAudioAdvanced",
"PreviewAudio",
"MaskPreview",
"MaskPreview+",
"ImageAndMaskPreview",
"VHS_VideoCombine",
"easy saveVideo",
"easy multitrackProjectVideoCombine",
"easy multiTrackInfoOutput",
"easy multiTrackTaskOutput",
"easy compareVideos",
"easy compareImages",
"easy showText",
"easy imageWall",
"CR Prompt Text",
"PreviewAny",
"easy clearGpuUsed",
"easy clearCacheAll",
"easy showSpentTime",
}
)
MODE_ACTIVE = 0
MODE_MUTED = 2
MODE_BYPASSED = 4
_SUBGRAPH_IN = -10 # pseudo-node id of a subgraph's boundary input
_SUBGRAPH_OUT = -20 # pseudo-node id of a subgraph's boundary output
@dataclass
class ConvertIssue:
node_id: str
code: str
message: str
level: str = "warning" # 'error' | 'warning'
@dataclass
class ConvertResult:
workflow: dict[str, Any] | None
status: str # 'ok' | 'warn' | 'fail'
issues: list[ConvertIssue] = field(default_factory=list)
@property
def needs_confirmation(self) -> bool:
"""Widget mapping uncertain — must not be imported as runnable."""
return any(
i.code in ("widget_unmapped", "widget_order_mismatch") for i in self.issues
)
def _norm_link(link: Any) -> tuple[int, int, int, int, int, str] | None:
"""(id, origin_id, origin_slot, target_id, target_slot, type) from either form."""
if isinstance(link, dict):
try:
return (
int(link["id"]),
int(link["origin_id"]),
int(link["origin_slot"]),
int(link["target_id"]),
int(link["target_slot"]),
str(link.get("type") or "*"),
)
except (KeyError, TypeError, ValueError):
return None
if isinstance(link, (list, tuple)) and len(link) >= 5:
try:
return (
int(link[0]),
int(link[1]),
int(link[2]),
int(link[3]),
int(link[4]),
str(link[5]) if len(link) > 5 and link[5] else "*",
)
except (TypeError, ValueError):
return None
return None
def _is_seed_like(name: str) -> bool:
lowered = name.lower()
return lowered in ("seed", "noise_seed", "value", "rand_seed", "random_seed") or (
lowered.endswith("seed")
)
def _is_upload_entry(name: str, type_: str) -> bool:
return type_.upper().endswith("UPLOAD") or name == "upload" or "UPLOAD" in type_.upper()
class _Converter:
"""One conversion run over a single UI-format document."""
def __init__(self, ui: dict[str, Any]) -> None:
self.ui = ui
self.issues: list[ConvertIssue] = []
self.subgraphs: dict[str, dict[str, Any]] = {
str(s.get("id")): s
for s in self.ui.get("definitions", {}).get("subgraphs", []) or []
if isinstance(s, dict)
}
# qualified id → UI node + owning scope
self.nodes: dict[str, dict[str, Any]] = {}
# scope key ('' for top level) → link table / node-id index / owner
self.scope_links: dict[str, dict[int, tuple[int, int, int, int, str]]] = {}
self.scope_ids: dict[str, dict[str, str]] = {}
self.scope_owner: dict[str, str] = {}
self._set_capture_cache: dict[str, dict[str, tuple[str, int]]] = {}
self._set_names: dict[str, set[str]] = {}
self._depth = 0
self._cycle_warned = False
self._set_building: set[str] = set()
# -------------------------------------------------------------- indexing
def _index_scope(self, scope: str, links: list[Any], owner: str | None) -> None:
table = self.scope_links.setdefault(scope, {})
for link in links or []:
norm = _norm_link(link)
if norm is not None:
table[norm[0]] = norm[1:]
self.scope_ids.setdefault(scope, {})
if owner is not None:
self.scope_owner[scope] = owner
def _flatten(
self, node: dict[str, Any], scope: str, prefix: str
) -> None:
qualified = f"{prefix}{node.get('id')}"
self.nodes[qualified] = {"node": node, "scope": scope}
self.scope_ids.setdefault(scope, {})[str(node.get("id"))] = qualified
sub = self.subgraphs.get(str(node.get("type") or ""))
if sub is None:
return
if not isinstance(sub.get("nodes"), list):
self.issues.append(
ConvertIssue(qualified, "subgraph_bad_definition", "子图缺少内部节点定义")
)
return
sub_scope = f"{scope}|{node.get('type')}" if scope else f"{node.get('type')}"
if sub_scope not in self.scope_links:
self._index_scope(sub_scope, sub.get("links") or [], qualified)
for inner in sub["nodes"]:
if isinstance(inner, dict):
self._flatten(inner, sub_scope, f"{qualified}:")
# --------------------------------------------------------------- lookups
def _node(self, qualified: str) -> dict[str, Any] | None:
entry = self.nodes.get(qualified)
return entry["node"] if entry else None
def _scope(self, qualified: str) -> str:
entry = self.nodes.get(qualified)
return entry["scope"] if entry else ""
def _link(self, scope: str, link_id: Any) -> tuple[int, int, int, int, str] | None:
if link_id is None:
return None
try:
key = int(link_id)
except (TypeError, ValueError):
return None
return self.scope_links.get(scope, {}).get(key)
def _boundary_source(self, scope: str, link_id: Any) -> tuple[str, int] | None:
"""(packed qualified id, boundary slot) when this link leaves a
subgraph through its -10 input boundary."""
link = self._link(scope, link_id)
if link is None or link[0] != _SUBGRAPH_IN:
return None
packed_q = self.scope_owner.get(scope)
if packed_q is None:
return None
return packed_q, link[1]
def _subgraph_of(self, qualified: str) -> dict[str, Any] | None:
node = self._node(qualified)
if node is None:
return None
return self.subgraphs.get(str(node.get("type") or ""))
@staticmethod
def _virtual_name(node: dict[str, Any]) -> str:
"""A Set/Get node's captured variable name (its first widget value)."""
named = node.get("widgets_values_named")
if isinstance(named, dict):
for key in ("const_name", "name", "value"):
if key in named:
return str(named[key])
values = node.get("widgets_values")
if isinstance(values, list) and values and isinstance(values[0], str):
return values[0]
return ""
def _set_captures(self, scope: str) -> dict[str, tuple[str, int]]:
"""{variable name → resolved source} for the Set nodes in one scope."""
table = self._set_capture_cache.get(scope)
if table is not None:
return table
table = {}
# Seed before resolving: a Set feeding a same-named Get (FOR-loop
# graphs) would otherwise recurse into this builder forever. A miss
# while building is not a missing Set — never warn mid-construction.
self._set_capture_cache[scope] = table
self._set_building.add(scope)
try:
return self._build_set_captures(scope, table)
finally:
self._set_building.discard(scope)
def _build_set_captures(
self, scope: str, table: dict[str, tuple[str, int]]
) -> dict[str, tuple[str, int]]:
names: set[str] = set()
for qualified, entry in self.nodes.items():
if entry["scope"] != scope:
continue
node = entry["node"]
if str(node.get("type") or "") not in _SET_TYPES:
continue
name = self._virtual_name(node)
if not name:
continue
names.add(name)
ins = self._inputs_of(node)
link = self._link(scope, ins[0].get("link")) if ins else None
resolved = self._resolve_link(scope, link)
if resolved is not None:
table.setdefault(name, resolved)
self._set_names[scope] = names
self._set_capture_cache[scope] = table
return table
@staticmethod
def _inputs_of(node: dict[str, Any]) -> list[dict[str, Any]]:
return [i for i in node.get("inputs") or [] if isinstance(i, dict)]
@staticmethod
def _outputs_of(node: dict[str, Any]) -> list[dict[str, Any]]:
return [o for o in node.get("outputs") or [] if isinstance(o, dict)]
# ------------------------------------------------------------- resolution
def _resolve_link(
self, scope: str, link: tuple[int, int, int, int, str] | None
) -> tuple[str, int] | None:
"""Resolve one link (in scope) to an API source (qualified id, slot)."""
if link is None:
return None
if self._depth > 200: # Set/Get cycles (FOR-loop graphs) never unwind
if not self._cycle_warned:
self._cycle_warned = True
self.issues.append(
ConvertIssue(
"",
"cyclic_variable",
"变量/Set-Get 连线存在循环(常见于 FOR 循环流),该连线按未连接处理",
)
)
return None
self._depth += 1
try:
return self._resolve_link_inner(scope, link)
finally:
self._depth -= 1
def _resolve_link_inner(
self, scope: str, link: tuple[int, int, int, int, str]
) -> tuple[str, int] | None:
origin_id, origin_slot = link[0], link[1]
if origin_id == _SUBGRAPH_IN:
# Boundary: hand off to the packed instance's own input slot.
packed_q = self.scope_owner.get(scope)
if packed_q is None:
return None
node = self._node(packed_q)
if node is None:
return None
entries = self._inputs_of(node)
if origin_slot >= len(entries):
return None
entry = entries[origin_slot]
if entry.get("link") is None:
return None # unlinked boundary → inner widget value applies
outer = self._scope(packed_q)
return self._resolve_link(outer, self._link(outer, entry.get("link")))
qualified = self.scope_ids.get(scope, {}).get(str(origin_id))
if qualified is None:
return None
return self._resolve_source(qualified, origin_slot)
def _resolve_source(self, qualified: str, slot: int) -> tuple[str, int] | None:
"""What an outgoing edge from (node, output slot) really connects to."""
node = self._node(qualified)
if node is None:
return None
node_type = str(node.get("type") or "")
mode = node.get("mode") or 0
scope = self._scope(qualified)
if node_type in _UI_ONLY_TYPES:
return None
if node_type in _REROUTE_TYPES:
ins = self._inputs_of(node)
if not ins:
return None
return self._resolve_link(scope, self._link(scope, ins[0].get("link")))
if node_type in _SET_TYPES:
ins = self._inputs_of(node)
if not ins:
return None
return self._resolve_link(scope, self._link(scope, ins[0].get("link")))
if node_type in _GET_TYPES:
name = self._virtual_name(node)
captured = self._set_captures(scope).get(name)
if captured is None and scope not in self._set_building:
if name in self._set_names.get(scope, set()):
self.issues.append(
ConvertIssue(
qualified,
"variable_upstream_dropped",
f"变量「{name}」的上游被旁路或未连接,取值按未连接处理",
)
)
else:
self.issues.append(
ConvertIssue(
qualified,
"get_without_set",
f"变量节点「{name}」没有对应的 Set 节点",
)
)
return captured
sub = self._subgraph_of(qualified)
if sub is not None:
outputs = [o for o in sub.get("outputs") or [] if isinstance(o, dict)]
if slot >= len(outputs):
return None
sub_scope = f"{scope}|{node_type}" if scope else node_type
for link in self.scope_links.get(sub_scope, {}).values():
if link[2] == _SUBGRAPH_OUT and link[3] == slot:
inner_q = self.scope_ids.get(sub_scope, {}).get(str(link[0]))
return self._resolve_source(inner_q, link[1]) if inner_q else None
return None # boundary output with no inner source
if mode == MODE_MUTED:
return None
if mode == MODE_BYPASSED:
return self._bypass_through(qualified, slot)
return (qualified, slot)
def _bypass_through(self, qualified: str, slot: int) -> tuple[str, int] | None:
"""Pass-through of a bypassed node: same-name input first, then same-type."""
node = self._node(qualified)
if node is None:
return None
outputs = self._outputs_of(node)
out = outputs[slot] if slot < len(outputs) else {}
out_name = str(out.get("name") or "")
out_type = str(out.get("type") or "*")
linkable = [
e for e in self._inputs_of(node) if not e.get("widget") and e.get("link") is not None
]
pick = None
if out_name:
pick = next((e for e in linkable if str(e.get("name") or "") == out_name), None)
if pick is None:
pick = next(
(
e
for e in linkable
if out_type == "*" or str(e.get("type") or "*") in (out_type, "*")
),
None,
)
if pick is None:
return None
scope = self._scope(qualified)
return self._resolve_link(scope, self._link(scope, pick.get("link")))
# --------------------------------------------------------- widget mapping
def _widget_entries(self, node: dict[str, Any]) -> list[tuple[str, str]]:
"""[(name, type)] of entries carrying the ``widget`` marker, in order."""
out: list[tuple[str, str]] = []
for entry in self._inputs_of(node):
marker = entry.get("widget")
if isinstance(marker, dict):
name = str(marker.get("name") or entry.get("name") or "")
if name:
out.append((name, str(entry.get("type") or "")))
return out
def _widgets_from_entries(
self,
node_id: str,
entries: list[tuple[str, str]],
values: Any,
) -> tuple[dict[str, Any], list[ConvertIssue]]:
"""Map widget values using the node's serialized input list."""
inputs: dict[str, Any] = {}
issues: list[ConvertIssue] = []
if isinstance(values, dict):
for name, type_ in entries:
if name in _BUTTON_WIDGETS or self._is_upload(name, type_):
continue
if name in values:
inputs[name] = values[name]
else:
issues.append(
ConvertIssue(node_id, "widget_unmapped", f"控件「{name}」没有序列化值")
)
return inputs, issues
if not isinstance(values, list):
return inputs, issues
if not values:
return inputs, issues # nothing serialized — server defaults apply
i = 0
for name, type_ in entries:
if name in _BUTTON_WIDGETS:
continue # button widgets never serialize
if i >= len(values):
issues.append(
ConvertIssue(node_id, "widget_order_mismatch", f"控件值数量不足({name})")
)
break
value = values[i]
i += 1
if self._is_upload(name, type_):
continue # consumes a slot, never an API input
inputs[name] = value
if (
type_.upper() == "INT"
and _is_seed_like(name)
and i < len(values)
and values[i] in _CONTROL_MODES
):
i += 1 # control_after_generate rides right after its seed
leftover_ok = all(_is_button_leftover(v) for v in values[i:])
if not issues and i != len(values) and not leftover_ok:
issues.append(
ConvertIssue(
node_id,
"widget_order_mismatch",
f"有 {len(values)} 个控件值,输入清单只覆盖 {i} 个",
)
)
return inputs, issues
@staticmethod
def _is_upload(name: str, type_: str) -> bool:
return _is_upload_entry(name, type_)
def _widgets_from_registry(
self, node: dict[str, Any], values: list[Any]
) -> tuple[dict[str, Any], list[ConvertIssue]]:
node_type = str(node.get("type") or "")
if not values:
return {}, [] # nothing serialized — server defaults apply
for order in WIDGET_ORDERS.get(node_type) or []:
inputs: dict[str, Any] = {}
i = 0
for name in order:
if name in _BUTTON_WIDGETS:
continue
if i >= len(values):
break
value = values[i]
i += 1
if _is_upload_entry(name, ""):
continue
inputs[name] = value
if _is_seed_like(name) and i < len(values) and values[i] in _CONTROL_MODES:
i += 1
if i == len(values) or all(_is_button_leftover(v) for v in values[i:]):
return inputs, [] # exact fit, or trailing button leftovers
return {}, [
ConvertIssue(
node.get("id", ""),
"widget_unmapped" if not WIDGET_ORDERS.get(node_type) else "widget_order_mismatch",
f"「{node_type}」的 {len(values)} 个控件值无法可靠对应到输入名",
)
]
def _packed_widget_values(self, qualified: str) -> Any:
"""Exposed widget values of a packed instance as {boundary name: value}."""
node = self._node(qualified)
sub = self._subgraph_of(qualified)
if node is None or sub is None:
return None
boundary = [i for i in sub.get("inputs") or [] if isinstance(i, dict)]
named = node.get("widgets_values_named")
if isinstance(named, dict):
return named
values = node.get("widgets_values")
if not isinstance(values, list):
return None
out: dict[str, Any] = {}
i = 0
for entry in boundary:
name = str(entry.get("name") or "")
type_ = str(entry.get("type") or "")
if name and i < len(values):
out[name] = values[i]
i += 1
if (
type_.upper() == "INT"
and _is_seed_like(name)
and i < len(values)
and values[i] in _CONTROL_MODES
):
i += 1
return out
# -------------------------------------------------------------- serialize
def _serialize_node(self, qualified: str) -> dict[str, Any] | None:
node = self._node(qualified)
if node is None:
return None
node_type = str(node.get("type") or "")
if (
not node_type
or node_type in _UI_ONLY_TYPES
or node_type in _REROUTE_TYPES
or node_type in _SET_TYPES
or node_type in _GET_TYPES
):
return None
scope = self._scope(qualified)
inputs: dict[str, Any] = {}
widget_values = node.get("widgets_values_named")
if not isinstance(widget_values, dict):
widget_values = node.get("widgets_values")
if self._subgraph_of(qualified) is not None:
widget_values = self._packed_widget_values(qualified)
widget_inputs: dict[str, Any] = {}
entries = self._widget_entries(node)
if widget_values is not None:
if entries:
widget_inputs, w_issues = self._widgets_from_entries(
qualified, entries, widget_values
)
self.issues.extend(w_issues)
elif isinstance(widget_values, dict):
widget_inputs = {
k: v for k, v in widget_values.items() if k not in _BUTTON_WIDGETS
}
elif isinstance(widget_values, list):
widget_inputs, w_issues = self._widgets_from_registry(node, widget_values)
self.issues.extend(w_issues)
for entry in self._inputs_of(node):
name = str(entry.get("name") or "")
if not name or name in inputs:
continue
if entry.get("link") is None:
continue # unconnected optional input; widget values applied below
# Widget entries fed from the subgraph boundary (-10): when the
# packed instance left that boundary slot unlinked, the value is
# the packed node's own exposed widget value, not the inner one.
boundary = self._boundary_source(scope, entry.get("link"))
if boundary is not None:
packed_q, boundary_slot = boundary
packed_node = self._node(packed_q)
packed_entries = self._inputs_of(packed_node) if packed_node else []
if boundary_slot < len(packed_entries) and packed_entries[
boundary_slot
].get("link") is None:
values = self._packed_widget_values(packed_q)
boundary_name = str(packed_entries[boundary_slot].get("name") or "")
if isinstance(values, dict) and boundary_name in values:
inputs[name] = values[boundary_name]
continue
# linked boundary falls through to normal resolution
link = self._link(scope, entry.get("link"))
if link is None:
self.issues.append(
ConvertIssue(qualified, "dangling_link", f"输入「{name}」的连线不存在")
)
continue
resolved = self._resolve_link(scope, link)
if resolved is not None:
inputs[name] = [resolved[0], resolved[1]]
elif entry.get("widget"):
pass # muted upstream: the widget value below takes over
else:
self.issues.append(
ConvertIssue(
qualified,
"muted_input_dropped",
f"输入「{name}」上游被静音,已按未连接处理",
)
)
for name, value in widget_inputs.items():
inputs.setdefault(name, value)
api_node: dict[str, Any] = {"class_type": node_type, "inputs": inputs}
title = str(node.get("title") or "").strip()
if title:
api_node["_meta"] = {"title": title}
return api_node
# ------------------------------------------------------------------- main
def run(self) -> ConvertResult:
nodes = self.ui.get("nodes")
if not isinstance(nodes, list) or not nodes:
return ConvertResult(
None,
"fail",
[ConvertIssue("", "no_nodes", "文件不含 nodes 数组,不是 UI 格式工作流", "error")],
)
try:
self._index_scope("", self.ui.get("links") or [], None)
for node in nodes:
if isinstance(node, dict):
self._flatten(node, "", "")
except RecursionError:
return ConvertResult(
None, "fail", [ConvertIssue("", "subgraph_recursion", "子图嵌套过深", "error")]
)
# Closure from known output nodes (ComfyUI's own graphToPrompt roots).
roots = [
q
for q, entry in self.nodes.items()
if str(entry["node"].get("type") or "") in _OUTPUT_CLASSES
and (entry["node"].get("mode") or 0) == MODE_ACTIVE
]
keep: dict[str, None] = {}
stack = list(roots)
while stack:
qualified = stack.pop()
if qualified in keep:
continue
entry = self.nodes.get(qualified)
if entry is None:
continue
keep[qualified] = None
node = entry["node"]
scope = entry["scope"]
for e in self._inputs_of(node):
if e.get("link") is None:
continue
link = self._link(scope, e.get("link"))
resolved = self._resolve_link(scope, link)
if resolved is not None:
stack.append(resolved[0])
if not keep:
# No known output node: keep every active computed node and warn —
# the import lint will report the missing output separately.
for qualified, entry in self.nodes.items():
node = entry["node"]
node_type = str(node.get("type") or "")
if (
node_type in _UI_ONLY_TYPES
or node_type in _REROUTE_TYPES
or (node.get("mode") or 0) != MODE_ACTIVE
or self._subgraph_of(qualified) is not None
):
continue
keep[qualified] = None
self.issues.append(
ConvertIssue(
"",
"no_output_node",
"图中没有已知输出节点(SaveImage / 视频合成等),"
"全部活跃节点已保留,需人工确认",
)
)
workflow: dict[str, Any] = {}
for qualified in keep:
api_node = self._serialize_node(qualified)
if api_node is not None and api_node["class_type"]:
workflow[qualified] = api_node
if not workflow:
return ConvertResult(
None,
"fail",
self.issues or [ConvertIssue("", "empty", "转换后没有任何节点", "error")],
)
status = (
"fail"
if any(i.level == "error" for i in self.issues)
else ("ok" if not self.issues else "warn")
)
return ConvertResult(workflow, status, self.issues)
def convert_ui_workflow(ui: dict[str, Any]) -> ConvertResult:
"""Convert one ComfyUI UI-format workflow dict into API format."""
if not isinstance(ui, dict) or not isinstance(ui.get("nodes"), list):
return ConvertResult(
None,
"fail",
[ConvertIssue("", "not_ui_format", "不是 UI 格式(缺少 nodes 数组)", "error")],
)
return _Converter(ui).run()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,146 @@
"""Check and coerce studio form values against a workflow's input schema."""
from __future__ import annotations
import math
from pathlib import Path
from typing import Any
def _fmt(n: Any) -> str:
return f"{n:g}" if isinstance(n, float) else str(n)
def validate_input_values(
schema: list[dict[str, Any]], values: dict[str, Any]
) -> tuple[dict[str, Any], list[str]]:
"""Return ``(clean_values, errors)``.
Numbers are coerced to int/float per the schema entry (``integer``,
``min``/``max``); select values must be one of ``options``. Blank values
are dropped so the workflow keeps its own value. Keys not in the schema
pass through untouched (other callers patch extra keys, e.g. uploads).
Auto-detected required entries (positive prompt, wired image slots,
reference-image lists) must be present — tagged workflows mark every
input required, and blank ones fall back to the workflow's value.
"""
by_key = {str(e.get("nodeId")): e for e in schema if isinstance(e, dict)}
clean: dict[str, Any] = {}
errors: list[str] = []
for key, raw in (values or {}).items():
entry = by_key.get(str(key))
if entry is None:
clean[key] = raw
continue
if raw is None or (isinstance(raw, str) and not raw.strip()):
continue
label = str(entry.get("label") or key)
kind = entry.get("kind")
if kind == "number":
if isinstance(raw, bool):
errors.append(f"「{label}」必须是数字")
continue
try:
num = float(raw)
except (TypeError, ValueError):
errors.append(f"「{label}」必须是数字")
continue
if not math.isfinite(num):
errors.append(f"「{label}」必须是有限数字")
continue
if entry.get("integer"):
if num != int(num):
errors.append(f"「{label}」必须是整数")
continue
value: int | float = int(num)
else:
value = int(num) if isinstance(raw, int) else num
lo, hi = entry.get("min"), entry.get("max")
if lo is not None and value < lo:
errors.append(f"「{label}」不能小于 {_fmt(lo)}")
continue
if hi is not None and value > hi:
errors.append(f"「{label}」不能大于 {_fmt(hi)}")
continue
clean[key] = value
elif kind in ("image_list", "video_list", "audio_list"):
paths = [raw] if isinstance(raw, str) else raw
if not isinstance(paths, list) or not all(isinstance(p, str) for p in paths):
errors.append(f"「{label}」的取值格式不对,请重新上传")
continue
paths = [p for p in paths if p.strip()]
unit = _LIST_UNITS[kind]
noun = _LIST_NOUNS[kind]
hi = entry.get("max")
if hi is not None and len(paths) > hi:
errors.append(
f"{label}最多 {hi} {unit}(当前 {len(paths)} {unit}),请删除多余的{noun}"
)
continue
bad = [
f"{label} {i}:{msg}"
for i, p in enumerate(paths, 1)
if (msg := _media_file_problem(p, kind))
]
if bad:
errors.extend(bad)
continue
if paths:
clean[key] = paths
elif kind == "select":
options = entry.get("options") or []
if options and raw not in options:
errors.append(f"「{label}」的取值「{raw}」不在工作流支持的选项中")
continue
clean[key] = raw
else:
clean[key] = raw
for key, entry in by_key.items():
required = entry.get("auto") and entry.get("required")
label = entry.get("label") or key
if required and entry.get("role") == "prompt":
has_fallback = str(entry.get("defaultValue") or "").strip() != ""
if key not in clean and not has_fallback:
errors.append(f"请填写「{label}」")
elif required and entry.get("kind") in ("image_list", "video_list", "audio_list"):
lo = entry.get("min") or 1
unit = _LIST_UNITS[entry.get("kind")]
if len(clean.get(key) or []) < lo and not any(label in e for e in errors):
errors.append(f"需要至少 {lo} {unit}{label}")
elif required and entry.get("kind") == "image" and entry.get("role") != "mask":
if key not in clean:
errors.append(f"请上传{label}")
return clean, errors
_IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".webp"})
_VIDEO_EXTS = frozenset({".mp4", ".webm", ".mov"})
_AUDIO_EXTS = frozenset({".mp3", ".wav", ".m4a"})
_LIST_UNITS = {"image_list": "张", "video_list": "段", "audio_list": "段"}
_LIST_NOUNS = {"image_list": "图片", "video_list": "视频", "audio_list": "音频"}
_LIST_EXTS = {"image_list": _IMAGE_EXTS, "video_list": _VIDEO_EXTS, "audio_list": _AUDIO_EXTS}
_LIST_FORMAT_HINT = {
"image_list": "格式不支持,请使用 PNG / JPG / WebP",
"video_list": "格式不支持,请使用 MP4 / WebM / MOV",
"audio_list": "格式不支持,请使用 MP3 / WAV / M4A",
}
def _media_file_problem(path: str, kind: str) -> str | None:
"""Why ``path`` cannot fill a media-list slot of this kind (None = fine)."""
from calliope import config
target = Path(path)
if target.suffix.lower() not in _LIST_EXTS[kind]:
return _LIST_FORMAT_HINT[kind]
try:
target.resolve().relative_to(config.settings.assets_dir.resolve())
except (ValueError, OSError):
return f"只能使用在本页上传或从生成结果选入的{_LIST_NOUNS[kind]}"
if not target.is_file():
return "文件已不存在,请重新上传"
return None
def _image_file_problem(path: str) -> str | None:
return _media_file_problem(path, "image_list")
+280 -2
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
import re
import sqlite3
from pathlib import Path
from typing import Any
@@ -121,7 +122,11 @@ CREATE TABLE IF NOT EXISTS workflows (
description TEXT,
prompt_profile TEXT NOT NULL DEFAULT 'prose',
is_enabled INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
usage TEXT,
usage_source TEXT,
output_map TEXT,
output_map_source TEXT
);
CREATE TABLE IF NOT EXISTS jobs (
@@ -138,7 +143,9 @@ CREATE TABLE IF NOT EXISTS jobs (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMP,
completed_at TIMESTAMP,
retry_count INTEGER NOT NULL DEFAULT 0
retry_count INTEGER NOT NULL DEFAULT 0,
comfy_prompt_id TEXT,
phase TEXT
);
CREATE TABLE IF NOT EXISTS agent_sessions (
@@ -147,11 +154,33 @@ CREATE TABLE IF NOT EXISTS agent_sessions (
title TEXT NOT NULL DEFAULT 'New chat',
status TEXT NOT NULL DEFAULT 'idle' CHECK(status IN ('idle', 'running', 'error')),
origin TEXT NOT NULL DEFAULT 'chat' CHECK(origin IN ('chat', 'scene')),
agent_key TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_agent_sessions_project ON agent_sessions(project_id);
-- Named-agent skill state (HEL-613): file content stays the truth in
-- <data_dir>/skills/<name>/SKILL.md; these tables only carry enable flags,
-- per-agent assignment, and edit snapshots for version/rollback.
CREATE TABLE IF NOT EXISTS skill_overrides (
skill_name TEXT PRIMARY KEY,
is_enabled INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS skill_bindings (
agent_key TEXT NOT NULL,
skill_name TEXT NOT NULL,
is_enabled INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY(agent_key, skill_name)
);
CREATE TABLE IF NOT EXISTS skill_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
skill_name TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_skill_versions_name ON skill_versions(skill_name);
CREATE TABLE IF NOT EXISTS agent_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
@@ -261,6 +290,71 @@ CREATE TABLE IF NOT EXISTS shot_capture (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_shot_capture_composition ON shot_capture(composition_id);
-- Five-stage creation workbench (HEL-614): one row per project per stage.
-- content_json is the working draft the user can edit; confirmed_content_json
-- is the last confirmed snapshot — the only version handed to the next agent
-- (unconfirmed drafts are never treated as final).
CREATE TABLE IF NOT EXISTS project_stages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
stage_key TEXT NOT NULL CHECK(stage_key IN ('script','style','storyboard','assets','shot')),
status TEXT NOT NULL DEFAULT 'todo' CHECK(status IN ('todo','running','awaiting_confirm','confirmed')),
content_json TEXT,
confirmed_content_json TEXT,
chosen_style_id TEXT,
draft_edited INTEGER NOT NULL DEFAULT 0,
error TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(project_id, stage_key)
);
CREATE INDEX IF NOT EXISTS idx_project_stages_project ON project_stages(project_id);
-- B4: immutable source identity, editable draft, optimistic concurrency.
CREATE TABLE IF NOT EXISTS agent_deliverables (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
source_message_id INTEGER NOT NULL,
project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
stage_key TEXT NOT NULL CHECK(stage_key IN ('script','style','storyboard','assets','shot')),
title TEXT NOT NULL,
content TEXT NOT NULL,
revision INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(session_id, source_message_id)
);
CREATE TABLE IF NOT EXISTS agent_deliverable_versions (
deliverable_id INTEGER NOT NULL REFERENCES agent_deliverables(id) ON DELETE CASCADE,
revision INTEGER NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(deliverable_id, revision)
);
CREATE TABLE IF NOT EXISTS result_attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_key TEXT NOT NULL,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
stage_key TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(source_key, project_id, stage_key)
);
-- Audit trail of agent runs inside the flow (HEL-614): instruction in,
-- normalized JSON artifact out, or a Chinese-readable failure reason.
CREATE TABLE IF NOT EXISTS stage_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
stage_key TEXT NOT NULL CHECK(stage_key IN ('script','style','storyboard','assets','shot')),
instruction TEXT,
output_json TEXT,
status TEXT NOT NULL DEFAULT 'running' CHECK(status IN ('running','done','failed')),
error TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_stage_runs_project ON stage_runs(project_id, stage_key);
"""
@@ -279,6 +373,111 @@ def _sqlite_connect(path: Path) -> sqlite3.Connection:
return sqlite3.connect(path)
def _backfill_workflow_usage(conn: sqlite3.Connection) -> None:
"""HEL-636: settle `workflows.usage` for rows that still have none.
Determinate graphs get ``usage='auto'``; indeterminate ones keep NULL
and are disabled (they must be confirmed on the connections page before
they can generate). Rows the user already confirmed (usage_source='user')
never pass through here again — their usage is set, so the WHERE clause
skips them. Old jobs / history / `kind` semantics are untouched.
"""
from calliope.comfyui.parser import classify_usage
rows = conn.execute(
"SELECT id, workflow_json FROM workflows WHERE usage IS NULL OR usage = ''"
).fetchall()
for row in rows: # plain connect — positional access
try:
workflow = json.loads(row[1])
except (json.JSONDecodeError, TypeError):
workflow = None
judgment = classify_usage(workflow) if isinstance(workflow, dict) else {}
if judgment.get("usage"):
conn.execute(
"UPDATE workflows SET usage = ?, usage_source = 'auto' WHERE id = ?",
(judgment["usage"], row[0]),
)
else:
conn.execute(
"UPDATE workflows SET is_enabled = 0 WHERE id = ? AND usage IS NULL",
(row[0],),
)
def _backfill_workflow_output_map(conn: sqlite3.Connection) -> None:
"""HEL-638: settle the output-node mapping for existing rows.
Rows the user already mapped (output_map_source='user') are never
touched. Determinate graphs get the trusted ``auto`` map. Rows whose
graph now contains unknown custom output candidates get no auto map and
are parked disabled — the usage decision survives, but until the user
designates the output nodes the workflow must not generate.
"""
from calliope.comfyui.output_map import auto_output_map, needs_user_output_map
rows = conn.execute(
"SELECT id, workflow_json FROM workflows"
" WHERE output_map_source IS NULL OR output_map_source != 'user'"
).fetchall()
for row in rows:
try:
workflow = json.loads(row[1])
except (json.JSONDecodeError, TypeError):
workflow = None
if not isinstance(workflow, dict):
continue
if needs_user_output_map(workflow):
conn.execute(
"UPDATE workflows SET output_map = NULL, output_map_source = NULL,"
" is_enabled = 0 WHERE id = ?",
(row[0],),
)
else:
conn.execute(
"UPDATE workflows SET output_map = ?, output_map_source = 'auto'"
" WHERE id = ?",
(json.dumps(auto_output_map(workflow)), row[0]),
)
# HEL-671: the user's H3 import went in under a first-frame-only name
# (“H3图生视频·首帧…”) although its graph is the FL2VA node that also takes
# a last frame. A row is renamed only when BOTH hold: its graph carries a
# wired MiniMaxH3ImageToVideo node, and its name still reads like a
# first-frame-only workflow — a row the user renamed, or any other graph,
# is left alone. Idempotent: the new name no longer matches.
_FIRST_FRAME_NAME_RE = re.compile(r"图生视频\s*[·\-—~]?\s*首帧")
_FL2VA_DESCRIPTION = "首帧必填,尾帧可选:只传首帧为图生视频,同时传尾帧为首尾帧生视频"
def _rename_workflows(conn: sqlite3.Connection) -> None:
for wid, name, wf_json in conn.execute(
"SELECT id, name, workflow_json FROM workflows"
).fetchall():
if not name or "尾帧" in name or not _FIRST_FRAME_NAME_RE.search(name):
continue
try:
workflow = json.loads(wf_json) if wf_json else None
except (json.JSONDecodeError, TypeError):
continue
if not isinstance(workflow, dict):
continue
fl2va = any(
isinstance(node, dict)
and str(node.get("class_type") or "") == "MiniMaxH3ImageToVideo"
and isinstance((node.get("inputs") or {}).get("first_frame"), list)
for node in workflow.values()
)
if not fl2va:
continue
conn.execute(
"UPDATE workflows SET name = ?,"
" description = COALESCE(NULLIF(description, ''), ?) WHERE id = ?",
(_FIRST_FRAME_NAME_RE.sub("首尾帧生视频", name), _FL2VA_DESCRIPTION, wid),
)
async def migrate_db(db_path: Path) -> None:
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
@@ -324,6 +523,45 @@ async def migrate_db(db_path: Path) -> None:
# comfyui_endpoints id). Dangling ids are tolerated — enqueue falls
# back to the active connection.
conn.execute("ALTER TABLE workflows ADD COLUMN endpoint_id TEXT")
# HEL-636: five-way usage (text2image…ref2video). NULL = pending human
# confirmation; `kind` keeps meaning output media for history/paths.
if "usage" not in cols:
conn.execute("ALTER TABLE workflows ADD COLUMN usage TEXT")
if "usage_source" not in cols:
conn.execute("ALTER TABLE workflows ADD COLUMN usage_source TEXT")
# HEL-638: explicit output-node designation for unknown custom output
# nodes ({nodeId, kind} JSON + 'user'|'auto'). Idempotent backfill
# below parks unmapped unknown-output rows (disabled) like usage does.
if "output_map" not in cols:
conn.execute("ALTER TABLE workflows ADD COLUMN output_map TEXT")
if "output_map_source" not in cols:
conn.execute("ALTER TABLE workflows ADD COLUMN output_map_source TEXT")
# HEL-681 (S2): library metadata for 150+ scale workflow browsing. Purely
# additive columns — old rows stay NULL/0 and every legacy path ignores
# them; the lightweight list view (?view=meta) is their only reader.
if "category" not in cols:
conn.execute("ALTER TABLE workflows ADD COLUMN category TEXT")
if "model_family" not in cols:
conn.execute("ALTER TABLE workflows ADD COLUMN model_family TEXT")
if "media_io" not in cols:
conn.execute("ALTER TABLE workflows ADD COLUMN media_io TEXT")
if "tags" not in cols:
conn.execute("ALTER TABLE workflows ADD COLUMN tags TEXT")
if "favorite" not in cols:
conn.execute("ALTER TABLE workflows ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0")
if "last_used_at" not in cols:
conn.execute("ALTER TABLE workflows ADD COLUMN last_used_at TIMESTAMP")
if "source" not in cols:
# where the row came from: 'ui_convert' (HEL-681 batch import) vs
# NULL = manual/API import as before.
conn.execute("ALTER TABLE workflows ADD COLUMN source TEXT")
if "source_path" not in cols:
conn.execute("ALTER TABLE workflows ADD COLUMN source_path TEXT")
# Idempotent backfill: pending rows are re-judged on every boot (parser
# upgrades can settle them); confirmed rows are never touched.
_backfill_workflow_usage(conn)
_backfill_workflow_output_map(conn)
_rename_workflows(conn)
scene_cols = {r[1] for r in conn.execute("PRAGMA table_info(scenes)").fetchall()}
if "location_id" not in scene_cols:
conn.execute("ALTER TABLE scenes ADD COLUMN location_id INTEGER")
@@ -363,15 +601,55 @@ async def migrate_db(db_path: Path) -> None:
WHERE kind = 'video' AND scene_id IS NOT NULL AND clip_id IS NULL
"""
)
# HEL-627: the ComfyUI prompt id a job was accepted under, and where the
# job really is (connecting / comfy_queued / comfy_running / saving;
# finished = real results saved; dry_run = placeholder, ComfyUI never called).
if "comfy_prompt_id" not in job_cols:
conn.execute("ALTER TABLE jobs ADD COLUMN comfy_prompt_id TEXT")
if "phase" not in job_cols:
conn.execute("ALTER TABLE jobs ADD COLUMN phase TEXT")
# HEL-643: where a job came from — 'user' (every normal generation) or
# 'workflow_test' (L3 试运行). Existing rows default to 'user', semantics
# unchanged; test jobs get isolated artifacts and no entity write-back.
if "origin" not in job_cols:
conn.execute("ALTER TABLE jobs ADD COLUMN origin TEXT NOT NULL DEFAULT 'user'")
# Build Scene's per-scene sessions: origin stamps WHERE a blind session was
# born ('chat' = user-created in AI Canvas, 'scene' = auto-created by the
# Build Scene rail). The session list excludes origin='scene' so scene
# sessions don't pollute the AI Canvas sidebar; Build Scene filters on it.
b4_cols = {r[1] for r in conn.execute("PRAGMA table_info(agent_sessions)")}
if "task_summary" not in b4_cols:
conn.execute("ALTER TABLE agent_sessions ADD COLUMN task_summary TEXT NOT NULL DEFAULT ''")
if "task_status" not in b4_cols:
conn.execute(
"ALTER TABLE agent_sessions ADD COLUMN task_status TEXT NOT NULL DEFAULT 'ready'"
)
session_cols = {r[1] for r in conn.execute("PRAGMA table_info(agent_sessions)").fetchall()}
if "origin" not in session_cols:
conn.execute("ALTER TABLE agent_sessions ADD COLUMN origin TEXT NOT NULL DEFAULT 'chat'")
# Backfill: existing Scene · N sessions were born in Build Scene.
conn.execute("UPDATE agent_sessions SET origin = 'scene' WHERE title LIKE 'Scene · %'")
if "agent_key" not in session_cols:
# HEL-613: named-agent sessions (five AIGC roles). NULL = a plain
# chat/scene session (every session created before this column).
conn.execute("ALTER TABLE agent_sessions ADD COLUMN agent_key TEXT")
# Seed per-agent bindings once, when the skill_bindings table is fresh:
# every skill that exists at upgrade time (the builtins) becomes visible
# to all five agents, so agent chats keep the current out-of-the-box
# behavior; new user skills opt in via the editor's 分配给 row.
binding_count = conn.execute("SELECT COUNT(*) FROM skill_bindings").fetchone()[0]
if binding_count == 0:
from calliope.agent.agents_registry import AGENT_KEYS
from calliope.agent.skills_store import ensure_builtin_skills, skills_root
ensure_builtin_skills()
for skill_dir in sorted(skills_root().glob("*/SKILL.md")):
name = skill_dir.parent.name
conn.executemany(
"INSERT OR IGNORE INTO skill_bindings(agent_key, skill_name, is_enabled) "
"VALUES (?, ?, 1)",
[(key, name) for key in AGENT_KEYS],
)
project_cols = {r[1] for r in conn.execute("PRAGMA table_info(projects)").fetchall()}
if "cover_path" not in project_cols:
conn.execute("ALTER TABLE projects ADD COLUMN cover_path TEXT")
+10 -2
View File
@@ -20,18 +20,24 @@ from calliope.routers import (
agent,
assets,
canvas,
comfyui as comfy,
events,
jobs,
library,
playground,
projects,
results,
scenes,
settings as settings_router,
shots,
story,
workbench,
workflows,
)
from calliope.routers import (
comfyui as comfy,
)
from calliope.routers import (
settings as settings_router,
)
logging.basicConfig(
level=logging.INFO,
@@ -140,7 +146,9 @@ def create_app(static_dir: Path | None = None) -> FastAPI:
app.include_router(library.router, prefix="/api/library", tags=["library"])
app.include_router(canvas.router, prefix="/api/canvas", tags=["canvas"])
app.include_router(shots.router, prefix="/api/shots", tags=["shots"])
app.include_router(results.router, prefix="/api/results", tags=["results"])
app.include_router(agent.router, prefix="/api/agent", tags=["agent"])
app.include_router(workbench.router, prefix="/api/workbench", tags=["workbench"])
app.include_router(events.router, prefix="/api/events", tags=["events"])
@app.get("/api/health")
@@ -39,6 +39,10 @@ class ProjectStats(BaseModel):
character_count: int
asset_ready_count: int
asset_total_count: int
# Five-stage workbench progress (HEL-614) — real stage rows + live jobs.
stage_confirmed: int = 0
stage_status: dict[str, str] = Field(default_factory=dict)
active_jobs: int = 0
class Project(BaseModel):
@@ -194,14 +198,35 @@ class ExpandClipsRequest(BaseModel):
clip_cap: int | None = None
WorkflowUsageKind = Literal[
"text2image",
"image2image",
"text2video",
"image2video",
"ref2video",
]
class WorkflowCreate(BaseModel):
name: str = Field(..., min_length=1)
kind: Literal["image", "video"] = "image"
kind: Literal["image", "video"] | None = None
workflow_json: dict[str, Any]
description: str | None = None
prompt_profile: Literal["prose", "minimax_h3_ref"] | None = None
# Optional binding to a ComfyUI connection id (settings.comfyui_endpoints).
endpoint_id: str | None = None
# HEL-636: five-way usage. Given by the import confirm page (possibly a
# user correction of the auto judgment); when absent it is classified
# from the graph, and indeterminate workflows stay pending (disabled).
usage: WorkflowUsageKind | None = None
usage_source: Literal["auto", "user"] | None = None
# Schema corrected on the confirm page (manual role overrides included).
input_schema: list[dict[str, Any]] | None = None
# HEL-638: explicit output-node designation for unknown custom output
# nodes — [{nodeId, kind: image|video}]. Required before a workflow
# with unknown outputs can be enabled or generated.
output_map: list[dict[str, Any]] | None = None
output_map_source: Literal["auto", "user"] | None = None
class WorkflowUpdate(BaseModel):
@@ -211,10 +236,83 @@ class WorkflowUpdate(BaseModel):
prompt_profile: Literal["prose", "minimax_h3_ref"] | None = None
is_enabled: bool | None = None
endpoint_id: str | None = None
usage: WorkflowUsageKind | None = None
usage_source: Literal["auto", "user"] | None = None
input_schema: list[dict[str, Any]] | None = None
# HEL-638: set/replace the user output mapping; an explicit empty list
# resets the workflow to pending confirmation.
output_map: list[dict[str, Any]] | None = None
output_map_source: Literal["auto", "user"] | None = None
# HEL-681 (S2): library metadata fields.
favorite: bool | None = None
tags: list[str] | None = None
category: str | None = None
model_family: str | None = None
class WorkflowUIFileItem(BaseModel):
"""One UI-format file for the HEL-681 convert/import pipeline."""
name: str | None = None
# repo-relative path (category/model-family evidence); display name
# defaults to the file stem.
source_path: str | None = None
workflow_json: dict[str, Any]
# optional human confirmations from the import wizard (S3): a settled
# usage and a corrected schema let warn files import enabled.
usage: WorkflowUsageKind | None = None
input_schema: list[dict[str, Any]] | None = None
output_map: list[dict[str, Any]] | None = None
description: str | None = None
tags: list[str] | None = None
class WorkflowConvertRequest(BaseModel):
"""Dry-run: per-file ok/warn/fail + suggested metadata, nothing stored."""
files: list[WorkflowUIFileItem]
# Include the converted API JSON per file (default on for small batches;
# the 146-file report leaves it off to keep the payload sane).
include_workflows: bool = False
# L2 preflight against a real ComfyUI (opt-in; needs a connection).
endpoint_id: str | None = None
refresh: bool = False
class WorkflowImportBatchRequest(BaseModel):
"""Commit: convert UI files and insert, skipping conflicts. Idempotent."""
files: list[WorkflowUIFileItem]
class WorkflowImportScanRequest(BaseModel):
"""Scan a server-side directory of UI JSONs (repo example library)."""
root: str = "example_ComfyUI_workflows"
# Set to preview what would import (same shape as /convert) — the safe
# default for a first run against the 146-file library.
dry_run: bool = True
class WorkflowAnalyze(BaseModel):
workflow_json: dict[str, Any]
# HEL-643: optional target connection — given, the analyze response also
# carries an L2 connection check (node/model availability via /object_info).
endpoint_id: str | None = None
# Bypass the /object_info cache (user re-checks after installing nodes).
refresh: bool = False
class WorkflowCheckRequest(BaseModel):
"""L2 re-check of a saved workflow against a live ComfyUI (HEL-643)."""
endpoint_id: str | None = None
refresh: bool = False
class WorkflowTestRequest(BaseModel):
"""Launch one real test run of a workflow (HEL-643 L3)."""
endpoint_id: str | None = None
input_values: dict[str, Any] = Field(default_factory=dict)
class JobCreate(BaseModel):
+28 -5
View File
@@ -19,7 +19,8 @@ class QueueManager:
conn = get_db(config.settings.db_path)
try:
cur = conn.execute(
"UPDATE jobs SET status = 'pending', started_at = NULL WHERE status = 'running'"
"UPDATE jobs SET status = 'pending', started_at = NULL, phase = NULL, "
"comfy_prompt_id = NULL WHERE status = 'running'"
)
conn.commit()
return cur.rowcount
@@ -35,17 +36,25 @@ class QueueManager:
scene_id: int | None = None,
clip_id: int | None = None,
payload: dict[str, Any] | None = None,
origin: str = "user",
) -> dict[str, Any]:
conn = get_db(config.settings.db_path)
try:
payload = self._stamp_endpoint(conn, workflow_id, dict(payload or {}))
cur = conn.execute(
"""
INSERT INTO jobs (project_id, scene_id, clip_id, kind, workflow_id, status, payload_json)
VALUES (?, ?, ?, ?, ?, 'pending', ?)
INSERT INTO jobs (project_id, scene_id, clip_id, kind, workflow_id,
status, payload_json, origin)
VALUES (?, ?, ?, ?, ?, 'pending', ?, ?)
""",
(project_id, scene_id, clip_id, kind, workflow_id, json.dumps(payload)),
(project_id, scene_id, clip_id, kind, workflow_id, json.dumps(payload), origin),
)
# HEL-681: every enqueued job counts as a use (最近使用).
if workflow_id is not None:
conn.execute(
"UPDATE workflows SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?",
(workflow_id,),
)
conn.commit()
row = conn.execute("SELECT * FROM jobs WHERE id = ?", (cur.lastrowid,)).fetchone()
return row_to_dict(row)
@@ -105,6 +114,20 @@ class QueueManager:
finally:
conn.close()
def set_phase(self, job_id: int, phase: str, comfy_prompt_id: str | None = None) -> None:
conn = get_db(config.settings.db_path)
try:
if comfy_prompt_id:
conn.execute(
"UPDATE jobs SET phase = ?, comfy_prompt_id = ? WHERE id = ?",
(phase, comfy_prompt_id, job_id),
)
else:
conn.execute("UPDATE jobs SET phase = ? WHERE id = ?", (phase, job_id))
conn.commit()
finally:
conn.close()
def mark_done(self, job_id: int, output_paths: list[str]) -> None:
conn = get_db(config.settings.db_path)
try:
@@ -142,7 +165,7 @@ class QueueManager:
conn.execute(
"""
UPDATE jobs SET status = 'pending', error = NULL, started_at = NULL,
completed_at = NULL WHERE id = ?
completed_at = NULL, phase = NULL, comfy_prompt_id = NULL WHERE id = ?
""",
(job_id,),
)
+195 -16
View File
@@ -8,9 +8,12 @@ from typing import Any
from pathlib import Path as _fs_path
import httpx
from calliope import config
from calliope.comfyui.client import ComfyUIClient
from calliope.comfyui.dry_run import write_placeholder_mp4, write_placeholder_png
from calliope.comfyui.lint import blocking_problem
from calliope.comfyui.parser import parse_dynamic_inputs
from calliope.comfyui.patcher import patch_workflow
from calliope.comfyui.roles import input_has_role
@@ -18,10 +21,37 @@ from calliope.db import get_db
from calliope.events.bus import event_bus
from calliope.export.runner import run_export
from calliope.queue.manager import queue_manager
from calliope.queue.workflow_test import (
TEST_ORIGIN,
prune_workflow_tests,
workflow_test_dir,
)
logger = logging.getLogger("calliope.worker")
def _comfy_messages(messages: list[Any]) -> str:
"""Pull the readable part out of ComfyUI's history status messages."""
parts: list[str] = []
for msg in messages:
if not (isinstance(msg, (list, tuple)) and len(msg) == 2):
continue
body = msg[1] if isinstance(msg[1], dict) else {}
node = body.get("node_type") or body.get("node_id") or "?"
if msg[0] == "execution_error":
reason = body.get("exception_message") or body.get("exception_type") or "未知错误"
parts.append(f"节点 {node}:{str(reason).strip()}")
elif msg[0] == "execution_interrupted":
parts.append(f"任务在节点 {node} 处被中断(在 ComfyUI 端被取消,或 ComfyUI 已重启)")
return ";".join(parts) or "ComfyUI 未给出原因"
# /queue lookups that must agree before a prompt counts as lost, and /history
# or /queue request failures in a row before the job gives up.
_MISSING_POLLS_LIMIT = 3
_POLL_ERRORS_LIMIT = 5
class _CancelledByUser(RuntimeError):
"""The job row was flipped to 'cancelled' out-of-band (Stop button).
The row already carries the terminal state; the loop must not overwrite
@@ -79,6 +109,7 @@ class QueueWorker:
job_payload = json.loads(job["payload_json"] or "{}")
except (json.JSONDecodeError, TypeError):
job_payload = {}
row = queue_manager.get_job(job["id"]) or {}
await event_bus.publish(
"job.completed",
{
@@ -90,6 +121,9 @@ class QueueWorker:
# Source stamp lets the UI attribute sandbox jobs
# to their enqueuer without racing the jobs poll.
"source": job_payload.get("source"),
"comfy_prompt_id": row.get("comfy_prompt_id"),
# Placeholder output — ComfyUI was never called.
"dry_run": row.get("phase") == "dry_run",
"message": f"{self._job_label(job)} · {len(outputs)} file(s)",
},
)
@@ -135,6 +169,16 @@ class QueueWorker:
"message": f"{self._job_label(job)} failed",
},
)
if str(job.get("origin") or "") == TEST_ORIGIN and job.get("workflow_id"):
# Retention: keep only the newest records (and their
# artifacts) per workflow (HEL-643).
try:
prune_workflow_tests(int(job["workflow_id"]))
except Exception:
logger.exception(
"Pruning workflow-test records for workflow %s failed",
job["workflow_id"],
)
except Exception:
logger.exception("Worker loop error")
await asyncio.sleep(config.settings.queue_poll_interval_sec)
@@ -144,6 +188,7 @@ class QueueWorker:
payload = json.loads(job["payload_json"] or "{}")
project_id = job["project_id"]
kind = job["kind"]
is_test = str(job.get("origin") or "") == TEST_ORIGIN
use_dry = bool(config.settings.dry_run)
if kind == "export":
@@ -154,9 +199,18 @@ class QueueWorker:
base_url, headers, endpoint_label = self._resolve_endpoint(payload)
client = ComfyUIClient(base_url, headers=headers)
try:
# A workflow test must be a REAL run — a dry-run placeholder that
# passes as "测试通过" is exactly the false positive HEL-643 bans.
if use_dry and is_test:
raise RuntimeError(
"测试工作流必须真实运行:当前设置开启了 Dry-run(占位模式),"
"请到「设置」关闭 Dry-run 后重新测试"
)
if use_dry:
queue_manager.set_phase(job["id"], "dry_run")
return await self._dry_run(job, payload)
queue_manager.set_phase(job["id"], "connecting")
healthy = await client.health()
if not healthy:
raise RuntimeError(
@@ -165,9 +219,24 @@ class QueueWorker:
)
workflow_id = job.get("workflow_id") or payload.get("workflow_id")
workflow = self._load_workflow(workflow_id)
if not workflow:
raise RuntimeError("No workflow found for job")
workflow_row = self._load_workflow_row(workflow_id)
if not workflow_row:
raise RuntimeError(
"找不到该任务使用的工作流(可能已被删除),请重新选择工作流后再生成"
)
workflow = workflow_row["workflow_json"]
broken = blocking_problem(workflow)
if broken:
raise RuntimeError(broken)
# HEL-638: harvest only the confirmed output nodes. A workflow
# with unknown custom outputs and no user mapping must not run.
node_kinds = self._output_node_kinds(workflow_row)
if node_kinds is None:
raise RuntimeError(
"该工作流含无法识别的自定义输出节点,尚未指定输出节点及媒体类型,"
"请到「ComfyUI 连接」页确认后再生成"
)
input_values = payload.get("input_values") or {}
if payload.get("continue_source") and kind == "video":
@@ -177,20 +246,66 @@ class QueueWorker:
patched = patch_workflow(workflow, input_values)
patched = await client.prepare_media_inputs(patched)
prompt_id = await client.queue_prompt(patched)
queue_manager.set_phase(job["id"], "comfy_queued", prompt_id)
await event_bus.publish(
"job.progress",
{
"job_id": job["id"],
"prompt_id": prompt_id,
"phase": "comfy_queued",
"message": f"ComfyUI 已接收任务(ID {prompt_id})",
},
)
history = await self._poll_history(client, prompt_id, job["id"])
if not history:
if queue_manager.is_cancelled(job["id"]):
raise _CancelledByUser()
raise RuntimeError("Timed out waiting for ComfyUI history")
raise RuntimeError(
f"等待 ComfyUI 出结果超时(任务 ID {prompt_id}):可在「设置 → 队列」调大超时,"
"或到 ComfyUI 检查该任务"
)
status = history.get("status") or {}
if status.get("status_str") == "error" or status.get("completed") is False:
messages = status.get("messages") or []
raise RuntimeError(f"ComfyUI error: {messages}")
raise RuntimeError(
f"ComfyUI 执行出错(任务 ID {prompt_id}):{_comfy_messages(messages)}"
)
outputs_meta = client.extract_outputs(history)
dest_dir = config.settings.assets_dir / str(project_id) / kind
outputs_meta = client.extract_outputs(history, node_kinds=node_kinds)
saved = [m for m in outputs_meta if m.get("type") == "output"]
outputs_meta = saved or outputs_meta
if not outputs_meta:
checked = "、".join(sorted(node_kinds))
raise RuntimeError(
f"ComfyUI 显示任务已结束(任务 ID {prompt_id}),但没有返回任何图片或视频,"
"不算生成完成。已按输出映射检查节点:" f"{checked};"
"请到 ComfyUI 历史中核对该任务,并确认所指定的输出节点正确。"
)
mismatch = next(
(
m
for m in outputs_meta
if m.get("media_kind") and m["media_kind"] != node_kinds.get(m["node_id"])
),
None,
)
if mismatch:
want = node_kinds.get(mismatch["node_id"])
raise RuntimeError(
f"输出节点 {mismatch['node_id']} 的媒体类型与映射不符"
f"(映射为{'视频' if want == 'video' else '图片'},"
f"实际返回 {mismatch['filename']}),任务不算生成完成。"
"请到「ComfyUI 连接」页修正该节点的媒体类型。"
)
queue_manager.set_phase(job["id"], "saving")
if is_test:
# Test artifacts live in a scratch dir, isolated from project
# media and never written back to entities (HEL-643).
dest_dir = workflow_test_dir(int(workflow_id))
else:
dest_dir = config.settings.assets_dir / str(project_id) / kind
dest_dir.mkdir(parents=True, exist_ok=True)
paths: list[str] = []
for meta in outputs_meta:
@@ -204,7 +319,9 @@ class QueueWorker:
)
paths.append(str(dest))
self._apply_outputs_to_entities(job, payload, paths)
if not is_test:
self._apply_outputs_to_entities(job, payload, paths)
queue_manager.set_phase(job["id"], "finished")
return paths
finally:
await client.close()
@@ -344,6 +461,9 @@ class QueueWorker:
# configurable (default 30 min) instead of a hardcoded 600 seconds.
attempts = None if timeout <= 0 else int(timeout / interval)
n = 0
phase = "comfy_queued"
missing = 0
errors = 0
while attempts is None or n < attempts:
n += 1
if self._stop.is_set():
@@ -354,17 +474,51 @@ class QueueWorker:
if queue_manager.is_cancelled(job_id):
await client.interrupt()
return None
history = await client.get_history(prompt_id)
if history:
return history
await asyncio.sleep(interval)
try:
history = await client.get_history(prompt_id)
if history:
return history
where, ahead = await client.locate_prompt(prompt_id)
except httpx.HTTPError as exc:
errors += 1
if errors >= _POLL_ERRORS_LIMIT:
raise RuntimeError(
f"连续 {errors} 次无法从 ComfyUI 查询任务状态(任务 ID {prompt_id}):"
f"{exc or type(exc).__name__}"
) from exc
await asyncio.sleep(interval)
continue
errors = 0
if where == "missing":
missing += 1
if missing >= _MISSING_POLLS_LIMIT:
raise RuntimeError(
f"ComfyUI 的队列和历史里都找不到该任务(任务 ID {prompt_id}),"
"可能 ComfyUI 已重启或任务被清除,没有产出结果"
)
else:
missing = 0
if where == "running":
phase = "comfy_running"
message = f"ComfyUI 正在执行(任务 ID {prompt_id})"
elif where == "pending":
phase = "comfy_queued"
message = f"ComfyUI 排队中,前面还有 {ahead} 个任务(任务 ID {prompt_id})"
else:
message = f"等待 ComfyUI 返回结果(任务 ID {prompt_id})"
if job_id is not None:
queue_manager.set_phase(job_id, phase)
await event_bus.publish(
"job.progress",
{
"job_id": job_id,
"prompt_id": prompt_id,
"message": f"Waiting on ComfyUI ({prompt_id[:8]}…)",
"phase": phase,
"queue_ahead": ahead if where == "pending" else None,
"message": message,
},
)
await asyncio.sleep(interval)
return None
def _mark_project_completed(self, project_id: int) -> None:
@@ -379,20 +533,45 @@ class QueueWorker:
finally:
conn.close()
def _load_workflow(self, workflow_id: int | None) -> dict[str, Any] | None:
def _load_workflow_row(self, workflow_id: int | None) -> dict[str, Any] | None:
if not workflow_id:
return None
conn = get_db(config.settings.db_path)
try:
row = conn.execute(
"SELECT workflow_json FROM workflows WHERE id = ?", (workflow_id,)
"SELECT workflow_json, output_map, output_map_source FROM workflows"
" WHERE id = ?",
(workflow_id,),
).fetchone()
if not row:
return None
return json.loads(row["workflow_json"])
return {
"workflow_json": json.loads(row["workflow_json"]),
"output_map": row["output_map"],
"output_map_source": row["output_map_source"],
}
finally:
conn.close()
@staticmethod
def _output_node_kinds(workflow_row: dict[str, Any]) -> dict[str, str] | None:
"""Confirmed {nodeId: image|video} for this workflow, or None when
the outputs are not confirmed (unknown nodes, no user mapping)."""
from calliope.comfyui.output_map import mapping_problem, resolve_output_map
workflow = workflow_row["workflow_json"]
problem = mapping_problem(
workflow, workflow_row.get("output_map"), workflow_row.get("output_map_source")
)
if problem:
return None
entries, _source = resolve_output_map(
workflow_row.get("output_map"),
workflow_row.get("output_map_source"),
workflow,
)
return {str(e["nodeId"]): str(e["kind"]) for e in entries} or None
def _job_label(self, job: dict[str, Any]) -> str:
payload = json.loads(job.get("payload_json") or "{}")
kind = job.get("kind") or "job"
@@ -0,0 +1,180 @@
"""Workflow-test job helpers (HEL-643).
A test job (jobs.origin = 'workflow_test') runs the FULL real pipeline —
queue → ComfyUI → outputs — but its artifacts are isolated from project
entities: files land under ``assets/_workflow_test/{workflow_id}/`` and are
never written back to characters/locations/scenes/clips.
This module owns the artifact directory, the retention policy (keep the
newest N records per workflow) and the purge paths used by "清除测试记录"
and workflow deletion.
"""
from __future__ import annotations
import json
import logging
import shutil
from pathlib import Path
from typing import Any
from calliope import config
from calliope.db import get_db
logger = logging.getLogger("calliope.queue")
TEST_ORIGIN = "workflow_test"
TEST_SOURCE = "workflow_test"
# Records (and artifacts) kept per workflow before older ones are pruned.
TEST_KEEP = 5
def workflow_test_dir(workflow_id: int) -> Path:
return config.settings.assets_dir / "_workflow_test" / str(workflow_id)
def _safe_unlink(path_str: str) -> bool:
"""Delete a file if it resolves under assets_dir (missing = already gone)."""
try:
target = Path(path_str).resolve()
target.relative_to(config.settings.assets_dir.resolve())
except (ValueError, OSError):
return False
if not target.is_file():
return False
try:
target.unlink()
return True
except OSError:
return False
def _unlink_job_outputs(job: dict[str, Any]) -> None:
try:
paths = json.loads(job.get("output_paths_json") or "[]")
except (json.JSONDecodeError, TypeError):
return
if not isinstance(paths, list):
return
for p in paths:
if isinstance(p, str) and p.strip():
_safe_unlink(p)
def _remove_test_dir_if_empty(workflow_id: int) -> None:
d = workflow_test_dir(workflow_id)
try:
if d.is_dir() and not any(d.iterdir()):
d.rmdir()
except OSError:
pass
def _test_job_public(job: dict[str, Any]) -> dict[str, Any]:
out = dict(job)
out["payload"] = json.loads(job.get("payload_json") or "{}")
out["output_paths"] = json.loads(job.get("output_paths_json") or "[]")
out.pop("payload_json", None)
out.pop("output_paths_json", None)
return out
def list_test_jobs(workflow_id: int, limit: int = 20) -> list[dict[str, Any]]:
conn = get_db(config.settings.db_path)
try:
rows = conn.execute(
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?"
" ORDER BY id DESC LIMIT ?",
(workflow_id, TEST_ORIGIN, limit),
).fetchall()
return [_test_job_public(dict(r)) for r in rows]
finally:
conn.close()
def find_active_test_job(workflow_id: int, endpoint_id: str | None) -> dict[str, Any] | None:
"""The unfinished test job for (workflow, endpoint), if one exists.
Idempotency guard for the "测试工作流" button: a second click re-uses the
in-flight job instead of enqueuing a duplicate.
"""
conn = get_db(config.settings.db_path)
try:
rows = conn.execute(
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?"
" AND status IN ('pending', 'running') ORDER BY id DESC",
(workflow_id, TEST_ORIGIN),
).fetchall()
finally:
conn.close()
for r in rows:
job = dict(r)
try:
payload = json.loads(job.get("payload_json") or "{}")
except (json.JSONDecodeError, TypeError):
payload = {}
snap = payload.get("endpoint") or {}
if not isinstance(snap, dict):
snap = {}
if (snap.get("id") or None) == (endpoint_id or None):
return _test_job_public(job)
return None
def prune_workflow_tests(workflow_id: int, keep: int = TEST_KEEP) -> int:
"""Keep only the newest ``keep`` test records (older rows + files go)."""
conn = get_db(config.settings.db_path)
try:
rows = conn.execute(
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?"
" ORDER BY id DESC LIMIT -1 OFFSET ?",
(workflow_id, TEST_ORIGIN, max(keep, 0)),
).fetchall()
removed = 0
for r in rows:
job = dict(r)
if job.get("status") in ("pending", "running"):
continue
_unlink_job_outputs(job)
conn.execute("DELETE FROM jobs WHERE id = ?", (job["id"],))
removed += 1
conn.commit()
finally:
conn.close()
_remove_test_dir_if_empty(workflow_id)
if removed:
logger.info("Pruned %s old workflow-test records for workflow %s", removed, workflow_id)
return removed
def purge_workflow_tests(workflow_id: int, *, allow_active: bool = False) -> int:
"""Delete EVERY test record and artifact for a workflow.
Returns the removed row count; raises RuntimeError when an unfinished
test job is still in flight (unless ``allow_active``).
"""
conn = get_db(config.settings.db_path)
try:
if not allow_active:
active = conn.execute(
"SELECT COUNT(*) AS n FROM jobs WHERE workflow_id = ? AND origin = ?"
" AND status IN ('pending', 'running')",
(workflow_id, TEST_ORIGIN),
).fetchone()["n"]
if active:
raise RuntimeError(
f"该工作流还有 {active} 个进行中的测试任务,请先等待完成或取消,再清除测试记录"
)
rows = conn.execute(
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?",
(workflow_id, TEST_ORIGIN),
).fetchall()
removed = 0
for r in rows:
_unlink_job_outputs(dict(r))
conn.execute("DELETE FROM jobs WHERE id = ?", (r["id"],))
removed += 1
conn.commit()
finally:
conn.close()
shutil.rmtree(workflow_test_dir(workflow_id), ignore_errors=True)
return removed
+202 -12
View File
@@ -21,6 +21,9 @@ class SessionCreate(BaseModel):
# (Build Scene's per-scene sessions). Scene-origin sessions are excluded
# from the generic session list so they never appear in the AI Canvas rail.
origin: str = "chat"
# WHICH named agent (剧本创作 etc., HEL-613) owns the session; None = a
# plain harness chat. Locked at creation; drives persona + LLM role.
agent_key: str | None = None
@field_validator("title")
@classmethod
@@ -36,6 +39,17 @@ class SessionCreate(BaseModel):
raise ValueError("origin must be 'chat' or 'scene'")
return v
@field_validator("agent_key")
@classmethod
def _bounded_agent_key(cls, v: str | None) -> str | None:
if v is None or v == "":
return None
from calliope.agent.agents_registry import AGENT_KEYS
if v not in AGENT_KEYS:
raise ValueError(f"agent_key must be one of {list(AGENT_KEYS)}")
return v
class SessionPatch(BaseModel):
title: str | None = None
@@ -83,7 +97,7 @@ class MemoryCreate(BaseModel):
class MessageCreate(BaseModel):
content: str = ""
mentions: list[MessageMention] = Field(default_factory=list, max_length=1)
mentions: list[MessageMention] = Field(default_factory=list, max_length=5)
attachments: list[MessageAttachment] = Field(default_factory=list, max_length=8)
answer_to: int | None = None # question/asked seq this message answers (card click)
@@ -99,8 +113,10 @@ class MessageCreate(BaseModel):
@field_validator("mentions")
@classmethod
def _one_workflow(cls, v: list[MessageMention]) -> list[MessageMention]:
if len(v) > 1:
if sum(m.type == "workflow" for m in v) > 1:
raise ValueError("Only one @workflow mention per message")
if sum(m.type == "skill" for m in v) > 4:
raise ValueError("At most four skill mentions per message")
return v
@@ -171,14 +187,22 @@ async def list_sessions(
project_id: int | None = None,
origin: str | None = None,
include_scene: bool = False,
agent_key: str | None = None,
) -> list[dict[str, Any]]:
"""Sessions for the sidebars. Scene-origin sessions (Build Scene's
per-scene chats) are excluded unless asked for by origin or flag — they
are Build Scene's rail entries, not AI Canvas chats."""
are Build Scene's rail entries, not AI Canvas chats. Named-agent
sessions (HEL-613) follow the same rule: hidden from the generic list
unless the caller filters by agent_key (the 智能体 page does)."""
conn = get_db(settings.db_path)
try:
where: list[str] = []
params: list[Any] = []
if agent_key is not None and agent_key != "":
where.append("agent_key = ?")
params.append(agent_key)
else:
where.append("agent_key IS NULL")
if project_id is not None:
where.append("project_id = ?")
params.append(project_id)
@@ -214,9 +238,10 @@ async def create_session(payload: SessionCreate) -> dict[str, Any]:
"SELECT id FROM projects WHERE id = ?", (payload.project_id,)
).fetchone():
raise HTTPException(status_code=404, detail="Project not found")
origin = "chat" if payload.agent_key else payload.origin
cur = conn.execute(
"INSERT INTO agent_sessions (title, project_id, origin) VALUES (?, ?, ?)",
(payload.title, payload.project_id, payload.origin),
"INSERT INTO agent_sessions (title, project_id, origin, agent_key) VALUES (?, ?, ?, ?)",
(payload.title, payload.project_id, origin, payload.agent_key),
)
conn.commit()
row = conn.execute("SELECT * FROM agent_sessions WHERE id = ?", (cur.lastrowid,)).fetchone()
@@ -259,7 +284,19 @@ async def get_session(session_id: int) -> dict[str, Any]:
session_log.backfill_from_messages(session_id)
events = session_log.read_events(session_id)
session["messages"] = _rows_to_messages(session_log.derive_chat_rows(events))
rows = _rows_to_messages(session_log.derive_chat_rows(events))
if session.get("agent_key"):
# Named-agent session (HEL-613): the loop logs main replies without
# agent_name — stamp the owning agent so bubbles/labels match. (The
# live SSE mirror in the runner applies the same stamp.)
from calliope.agent.agents_registry import AGENTS
owner = AGENTS.get(session["agent_key"], {}).get("name")
if owner:
for row in rows:
if row.get("role") == "assistant" and not row.get("agent_name"):
row["agent_name"] = owner
session["messages"] = rows
session["plan"] = session_log.derive_plan(events)
return session
@@ -505,13 +542,166 @@ async def delete_memory(memory_id: int) -> dict[str, Any]:
return {"ok": True}
@router.get("/skills")
async def list_skills() -> list[dict[str, Any]]:
"""Frontmatter-only skill list for the composer's `/` typeahead (same
view the agent's list_skills tool sees; no file bodies)."""
from calliope.agent.skills_store import list_skills as store_list_skills
@router.get("/agents")
async def list_named_agents() -> list[dict[str, Any]]:
"""The five named agents (HEL-613) with their enabled-skill counts —
powers the 智能体 page's left column. Names/personas are server-side."""
from calliope.agent.agents_registry import AGENTS, public_list
from calliope.agent.skills_store import (
skill_binding_map,
skill_enable_states,
)
return store_list_skills()
bindings = skill_binding_map()
enabled = skill_enable_states()
out: list[dict[str, Any]] = []
for info in public_list():
count = sum(
1
for skill, per_agent in bindings.items()
if per_agent.get(info["key"]) is True and enabled.get(skill, True)
)
info = {**info, "skill_count": count}
info["model_profile_id"] = (settings.agent_llm_assignments or {}).get(
AGENTS[info["key"]]["llm_role"]
)
out.append(info)
return out
@router.get("/skills")
async def list_skills(all: bool = False) -> list[dict[str, Any]]:
"""Frontmatter-only skill list for the composer's `/` typeahead (same
view the agent's list_skills tool sees; no file bodies). `all=true` is
the management view (HEL-613): every skill, unlimited, with enable
flags and per-agent assignment."""
from calliope.agent.skills_store import (
list_skills as store_list_skills,
)
from calliope.agent.skills_store import (
skill_binding_map,
skill_enable_states,
)
skills = store_list_skills(cap=None) if all else store_list_skills()
if not all:
return skills
enabled = skill_enable_states()
bindings = skill_binding_map()
for s in skills:
s["is_enabled"] = enabled.get(s["dir"], True)
s["bindings"] = bindings.get(s["dir"], {})
s["assigned_count"] = sum(1 for v in s["bindings"].values() if v)
return skills
class SkillUpsert(BaseModel):
description: str = Field(default="", max_length=500)
version: str = Field(default="", max_length=40)
tags: list[str] = Field(default_factory=list, max_length=12)
skill_type: str = ""
content: str = Field(default="", max_length=200_000)
bindings: dict[str, bool] | None = None
class SkillEnablePatch(BaseModel):
is_enabled: bool
class SkillBindingsPut(BaseModel):
bindings: dict[str, bool]
@router.put("/skills/{name}")
async def upsert_skill(name: str, payload: SkillUpsert) -> dict[str, Any]:
"""Create or edit a user skill: snapshot old → write SKILL.md → apply
the assignment matrix in one shot (HEL-613)."""
from calliope.agent.agents_registry import AGENT_KEYS
from calliope.agent.skills_store import (
set_skill_bindings,
write_skill,
)
if payload.skill_type and payload.skill_type not in ("template", "knowledge", "rules"):
raise HTTPException(status_code=422, detail="skill_type must be template|knowledge|rules")
if payload.bindings is not None:
unknown = [k for k in payload.bindings if k not in AGENT_KEYS]
if unknown:
raise HTTPException(
status_code=422, detail=f"unknown agent_key(s): {unknown}"
)
try:
summary = write_skill(
name,
description=payload.description,
version=payload.version,
tags=payload.tags,
skill_type=payload.skill_type,
body=payload.content,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
if payload.bindings is not None:
set_skill_bindings(name, payload.bindings)
return {"ok": True, "skill": summary}
@router.patch("/skills/{name}")
async def patch_skill(name: str, payload: SkillEnablePatch) -> dict[str, Any]:
"""Global enable/disable — an disabled skill disappears from every
session's discovery list immediately."""
from calliope.agent.skills_store import set_skill_enabled
set_skill_enabled(name, payload.is_enabled)
return {"ok": True, "skill": name, "is_enabled": payload.is_enabled}
@router.put("/skills/{name}/bindings")
async def put_skill_bindings(name: str, payload: SkillBindingsPut) -> dict[str, Any]:
"""Replace the skill's assignment matrix ({agent_key: enabled})."""
from calliope.agent.agents_registry import AGENT_KEYS
from calliope.agent.skills_store import set_skill_bindings
unknown = [k for k in payload.bindings if k not in AGENT_KEYS]
if unknown:
raise HTTPException(status_code=422, detail=f"unknown agent_key(s): {unknown}")
set_skill_bindings(name, payload.bindings)
return {"ok": True, "skill": name, "bindings": payload.bindings}
@router.delete("/skills/{name}")
async def delete_skill(name: str) -> dict[str, Any]:
"""Delete a skill: dir + bindings + overrides + version history."""
from calliope.agent.skills_store import delete_skill as store_delete
try:
store_delete(name)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return {"ok": True, "skill": name}
@router.get("/skills/{name}/versions")
async def get_skill_versions(name: str) -> list[dict[str, Any]]:
from calliope.agent.skills_store import list_versions
return list_versions(name)
@router.post("/skills/{name}/versions/{version_id}/restore")
async def restore_skill_version(name: str, version_id: int) -> dict[str, Any]:
"""Restore a snapshot: the current file is snapshotted first, then the
old content is written back verbatim (its frontmatter comes with it)."""
from calliope.agent.skills_store import restore_skill_file, version_content
content = version_content(name, version_id)
if content is None:
raise HTTPException(status_code=404, detail="Version not found")
try:
summary = restore_skill_file(name, content)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return {"ok": True, "skill": summary}
@router.get("/skills/path")
@@ -77,8 +77,11 @@ def _parsed_url(base_url: str) -> tuple[str, str]:
url = (base_url or "").strip()
if not url:
raise HTTPException(status_code=400, detail="API 地址不能为空")
if "://" not in url:
url = f"http://{url}"
# HEL-619: the server validates format as strictly as the UI already
# does. A schemeless garbled string used to be silently prefixed with
# http:// and then reported as a DNS failure; it is a 400 format error.
if "://" not in url or any(ch.isspace() for ch in url):
raise HTTPException(status_code=400, detail="地址必须是 http:// 或 https:// 开头的有效 URL")
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise HTTPException(status_code=400, detail="地址必须是 http:// 或 https:// 开头的有效 URL")
@@ -135,7 +138,12 @@ async def list_endpoints() -> dict[str, Any]:
@router.post("/endpoints")
async def create_endpoint(payload: EndpointIn) -> dict[str, Any]:
base_url, _host = _parsed_url(payload.base_url)
base_url, host = _parsed_url(payload.base_url)
# HEL-619: the same SSRF guard as the connectivity test applies on
# save — otherwise a blocked address can be stored and later probed by
# the worker path. Unresolvable hosts still pass (resolution happens
# again at probe time).
_assert_not_ssrf(host)
incoming = [ep for ep in settings.comfyui_endpoints or []]
incoming.append(payload.model_dump() | {"base_url": base_url})
try:
@@ -151,7 +159,8 @@ async def update_endpoint(endpoint_id: str, payload: EndpointPatch) -> dict[str,
_stored_endpoint(endpoint_id)
base_url = payload.base_url
if base_url is not None:
base_url, _host = _parsed_url(base_url)
base_url, host = _parsed_url(base_url)
_assert_not_ssrf(host) # same save-time guard as create (HEL-619)
patch = payload.model_dump(exclude_unset=True)
if base_url is not None:
patch["base_url"] = base_url
@@ -1,4 +1,5 @@
"""Standalone ComfyUI playground — generate freely, attach favorites to projects."""
from __future__ import annotations
import json
@@ -13,6 +14,10 @@ from pydantic import BaseModel, Field
from calliope import config
from calliope.agent.prompts import item_reference_prompt
from calliope.comfyui.lint import blocking_problem
from calliope.comfyui.output_map import mapping_problem
from calliope.comfyui.parser import effective_input_schema
from calliope.comfyui.validate import validate_input_values
from calliope.db import get_db, row_to_dict
from calliope.events.bus import event_bus
from calliope.queue.manager import queue_manager
@@ -27,6 +32,23 @@ PLAYGROUND_TITLE = "Playground Scratch"
# page can label origins ("独立生图" vs canvas Playground) without guessing.
PlaygroundSource = Literal["playground", "studio-image", "studio-video"]
# HEL-684: a desk visit opened from a five-stage workbench row may carry
# that row as optional context. Only real, server-verified project/stage/
# source info is accepted — the source row must still exist in the stage
# content, and the desk kind must match what that stage renders.
WorkbenchStage = Literal["assets", "shot"]
WORKBENCH_KIND_BY_STAGE: dict[str, str] = {"assets": "image", "shot": "video"}
WORKBENCH_ROW_KEY_BY_STAGE: dict[str, str] = {"assets": "assets", "shot": "shots"}
class PlaygroundWorkbenchContext(BaseModel):
project_id: int
stage: WorkbenchStage
source_kind: Literal["asset", "shot"]
source_id: str = Field(min_length=1)
# Display-only echo of the DeskContext label (e.g. 镜头 S1).
label: str | None = None
class PlaygroundGenerate(BaseModel):
workflow_id: int
@@ -35,6 +57,13 @@ class PlaygroundGenerate(BaseModel):
# then the active one (resolved in queue_manager.enqueue).
endpoint_id: str | None = None
source: PlaygroundSource = "playground"
# Optional real workbench source — validated, then stamped on the job
# payload so a finished take can be flowed back (attach-workbench).
context: PlaygroundWorkbenchContext | None = None
class PlaygroundAttachWorkbench(BaseModel):
job_id: int
class PlaygroundAttach(BaseModel):
@@ -79,6 +108,72 @@ def ensure_playground_project(conn) -> int:
return int(cur.lastrowid)
# ─────────────────────────────────────────────────────────────────────────
# HEL-684: workbench source context — validate against real rows, never
# guess. Both generate-time (record on the job payload) and attach-time
# (flow a finished take back) go through the same checks.
# ─────────────────────────────────────────────────────────────────────────
def _workbench_target_row(conn, ctx: PlaygroundWorkbenchContext) -> dict[str, Any] | None:
"""The real asset/shot row this context points at, or None."""
stage = conn.execute(
"SELECT content_json FROM project_stages WHERE project_id = ? AND stage_key = ?",
(ctx.project_id, ctx.stage),
).fetchone()
if not stage or not stage["content_json"]:
return None
try:
content = json.loads(stage["content_json"])
except (json.JSONDecodeError, TypeError):
return None
rows = content.get(WORKBENCH_ROW_KEY_BY_STAGE[ctx.stage])
if not isinstance(rows, list):
return None
for row in rows:
if isinstance(row, dict) and str(row.get("id") or "") == ctx.source_id:
return row
return None
def _validate_workbench_context(conn, ctx: PlaygroundWorkbenchContext, kind: str) -> dict[str, Any]:
"""Reject anything that is not a real, current project/stage/source.
Returns the payload stamp for the job. Raises on any mismatch so no
guessed or stale source is ever recorded.
"""
expected_kind = WORKBENCH_KIND_BY_STAGE[ctx.stage]
expected_source_kind = "asset" if ctx.stage == "assets" else "shot"
if ctx.source_kind != expected_source_kind or kind != expected_kind:
raise HTTPException(
status_code=422,
detail=(
f"来源上下文与生成类型不符:「{ctx.stage}」阶段需要{expected_kind}生成。"
"可关闭来源提示后独立生成。"
),
)
project = conn.execute(
"SELECT id, status FROM projects WHERE id = ?", (ctx.project_id,)
).fetchone()
if not project:
raise HTTPException(status_code=404, detail="来源项目不存在,无法记录回流上下文")
if project["status"] == PLAYGROUND_STATUS:
raise HTTPException(status_code=400, detail="不能以 playground 隐藏项目作为回流来源")
if _workbench_target_row(conn, ctx) is None:
raise HTTPException(
status_code=409,
detail=(
"来源"
f"{'资产' if ctx.source_kind == 'asset' else '镜头'}行已不存在"
"(阶段内容可能已重新生成)。可关闭来源提示后独立生成。"
),
)
stamp: dict[str, Any] = ctx.model_dump()
if not (stamp.get("label") or "").strip():
stamp["label"] = None
return stamp
def _assert_path_in_assets(path: str) -> Path:
assets_root = config.settings.assets_dir.resolve()
target = Path(path).resolve()
@@ -191,9 +286,36 @@ async def list_playground_jobs(limit: int = 50) -> list[dict[str, Any]]:
conn = get_db(config.settings.db_path)
try:
pid = ensure_playground_project(conn)
# HEL-684: which takes have already been flowed back to a workbench
# row (per source project) — the desk shows an honest 已回流 state
# that survives reloads instead of guessing from client memory.
links_by_project: dict[int, dict[int, dict[str, Any]]] = {}
rows = conn.execute(
"SELECT DISTINCT project_id FROM jobs"
" WHERE payload_json LIKE '%\"from_playground_job\"%'"
).fetchall()
for r in rows:
proj_id = int(r["project_id"])
links_by_project[proj_id] = _workbench_link_rows(conn, proj_id)
finally:
conn.close()
return [_job_public(j) for j in queue_manager.list_jobs(pid, None, limit)]
# Workflow-test jobs (HEL-643) ride the same scratch project but must not
# surface in the studio windows' history — they carry their own dialog.
jobs = [
j
for j in queue_manager.list_jobs(pid, None, limit)
if str(j.get("origin") or "user") != "workflow_test"
]
out = []
for j in jobs:
pub = _job_public(j)
stamp = (pub.get("payload") or {}).get("workbench")
if isinstance(stamp, dict) and isinstance(stamp.get("project_id"), int):
link = links_by_project.get(int(stamp["project_id"]), {}).get(int(pub["id"]))
if link and link.get("target_id") == stamp.get("source_id"):
pub["workbench_attach"] = link
out.append(pub)
return out
@router.delete("/jobs/{job_id}")
@@ -228,6 +350,21 @@ async def delete_playground_job(job_id: int) -> dict[str, Any]:
conn = get_db(config.settings.db_path)
try:
_clear_entity_refs(conn, [p for p in paths if isinstance(p, str)])
# HEL-684: the output files are about to vanish — drop the workbench
# link rows created from this take so no project row points at
# missing media (mirrors the entity-ref clearing above).
removed_links: list[int] = []
link_rows = conn.execute(
"SELECT id, payload_json FROM jobs WHERE payload_json LIKE '%\"from_playground_job\"%'"
).fetchall()
for r in link_rows:
try:
p = json.loads(r["payload_json"] or "{}")
except (json.JSONDecodeError, TypeError):
continue
if p.get("from_playground_job") == job_id:
conn.execute("DELETE FROM jobs WHERE id = ?", (int(r["id"]),))
removed_links.append(int(r["id"]))
conn.commit()
finally:
conn.close()
@@ -250,6 +387,7 @@ async def delete_playground_job(job_id: int) -> dict[str, Any]:
"job_id": job_id,
"deleted_files": deleted_files,
"missing_files": missing_files,
"removed_workbench_links": removed_links,
}
@@ -332,26 +470,57 @@ async def generate(payload: PlaygroundGenerate) -> dict[str, Any]:
try:
pid = ensure_playground_project(conn)
wf = conn.execute(
"SELECT id, kind, is_enabled FROM workflows WHERE id = ?",
"SELECT id, kind, is_enabled, workflow_json, input_schema, output_map,"
" output_map_source FROM workflows WHERE id = ?",
(payload.workflow_id,),
).fetchone()
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
raise HTTPException(
status_code=404, detail="工作流不存在(可能已被删除),请刷新页面后重新选择"
)
if not wf["is_enabled"]:
raise HTTPException(status_code=400, detail="Workflow is disabled")
raise HTTPException(
status_code=400, detail="该工作流已停用,请到「ComfyUI 连接」页启用后再生成"
)
kind = wf["kind"]
workflow_json = json.loads(wf["workflow_json"])
# HEL-638: a workflow with unknown custom output nodes must have a
# confirmed output mapping before anything is queued.
map_problem = mapping_problem(workflow_json, wf["output_map"], wf["output_map_source"])
if map_problem:
raise HTTPException(status_code=400, detail=map_problem)
schema = effective_input_schema(
json.loads(wf["input_schema"]) if wf["input_schema"] else [], workflow_json
)
# HEL-684: an optional workbench source is only recorded when it is
# real and current — the job itself still runs on the playground
# scratch project so the desk feed, SSE and tasks stay unchanged.
workbench_stamp = (
_validate_workbench_context(conn, payload.context, kind)
if payload.context is not None
else None
)
finally:
conn.close()
broken = blocking_problem(workflow_json)
if broken:
raise HTTPException(status_code=400, detail=broken)
input_values, problems = validate_input_values(schema, payload.input_values)
if problems:
raise HTTPException(status_code=400, detail="参数有误:" + ";".join(problems))
job = queue_manager.enqueue(
project_id=pid,
kind=kind,
workflow_id=payload.workflow_id,
scene_id=None,
payload={
"input_values": payload.input_values,
"input_values": input_values,
"source": payload.source,
**({"endpoint_id": payload.endpoint_id} if payload.endpoint_id else {}),
**({"workbench": workbench_stamp} if workbench_stamp else {}),
},
)
await event_bus.publish(
@@ -462,3 +631,167 @@ async def attach_to_project(payload: PlaygroundAttach) -> dict[str, Any]:
return out
finally:
conn.close()
def _workbench_link_rows(conn, project_id: int) -> dict[int, dict[str, Any]]:
"""playground job id → its flow-back link row on this project.
Link rows are done jobs with payload.source='workbench' and a
from_playground_job stamp — the exact shape _workbench_job_outputs
already enriches the workbench rows from, so a flowed-back take shows
up on its source row through the existing display path.
"""
out: dict[int, dict[str, Any]] = {}
rows = conn.execute(
"SELECT id, payload_json FROM jobs WHERE project_id = ? ORDER BY id ASC",
(project_id,),
).fetchall()
for r in rows:
try:
p = json.loads(r["payload_json"] or "{}")
except (json.JSONDecodeError, TypeError):
continue
if p.get("source") != "workbench":
continue
src = p.get("from_playground_job")
if isinstance(src, int):
out[src] = {
"job_id": int(r["id"]),
"stage": p.get("stage_key"),
"target_id": p.get("target_id"),
}
return out
@router.post("/attach-workbench")
async def attach_workbench(payload: PlaygroundAttachWorkbench) -> dict[str, Any]:
"""Flow a finished desk take back to its workbench source row (HEL-684).
The source is whatever real context the generate request carried —
nothing is guessed here. Re-attaching is idempotent: the existing link
row is returned, no duplicate is created, nothing is overwritten.
"""
conn = get_db(config.settings.db_path)
try:
pid = ensure_playground_project(conn)
job = queue_manager.get_job(payload.job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
if int(job["project_id"]) != int(pid):
raise HTTPException(status_code=403, detail="Not a playground job")
try:
job_payload = json.loads(job.get("payload_json") or "{}")
except (json.JSONDecodeError, TypeError):
job_payload = {}
stamp = job_payload.get("workbench")
if not isinstance(stamp, dict) or not stamp.get("source_id"):
raise HTTPException(
status_code=400, detail="该记录没有项目来源上下文,无法回流(独立生成不回流)"
)
if job["status"] != "done":
raise HTTPException(status_code=400, detail="生成完成后才能回流")
outputs = json.loads(job.get("output_paths_json") or "[]")
if not isinstance(outputs, list) or not outputs:
raise HTTPException(status_code=400, detail="该记录没有产物文件,无法回流")
try:
ctx = PlaygroundWorkbenchContext(
project_id=int(stamp["project_id"]),
stage=stamp.get("stage"),
source_kind=stamp.get("source_kind"),
source_id=str(stamp["source_id"]),
label=stamp.get("label"),
)
except Exception as exc: # pydantic ValidationError / missing keys
raise HTTPException(
status_code=400, detail="该记录的项目来源上下文无效,无法回流"
) from exc
# Re-validate against the live stage content — the row may have been
# regenerated since the take was created; refuse rather than mis-write.
if _workbench_target_row(conn, ctx) is None:
raise HTTPException(
status_code=409,
detail=(
"来源"
f"{'资产' if ctx.source_kind == 'asset' else '镜头'}行已不存在"
"(阶段内容可能已重新生成),未回流。"
),
)
links = _workbench_link_rows(conn, ctx.project_id)
existing = links.get(int(job["id"]))
if existing and existing.get("target_id") == ctx.source_id:
return {
"ok": True,
"already_attached": True,
"job_id": int(job["id"]),
"attached_job_id": existing["job_id"],
"project_id": ctx.project_id,
"stage": ctx.stage,
"target_id": ctx.source_id,
}
link_payload = {
"source": "workbench",
"stage_key": ctx.stage,
"target_id": ctx.source_id,
"from_playground_job": int(job["id"]),
"input_values": job_payload.get("input_values") or {},
}
if ctx.label:
link_payload["label"] = ctx.label
cur = conn.execute(
"""
INSERT INTO jobs (project_id, kind, workflow_id, status, payload_json,
output_paths_json, created_at, started_at, completed_at, origin)
VALUES (?, ?, ?, 'done', ?, ?,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'user')
""",
(
ctx.project_id,
job["kind"],
job["workflow_id"],
json.dumps(link_payload, ensure_ascii=False),
json.dumps([str(p) for p in outputs if isinstance(p, str)]),
),
)
conn.execute(
"UPDATE projects SET updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(ctx.project_id,),
)
conn.commit()
link_id = int(cur.lastrowid)
finally:
conn.close()
await event_bus.publish(
"job.completed",
{
"job_id": link_id,
"kind": job["kind"],
"outputs": outputs,
"project_id": ctx.project_id,
"source": "workbench",
"message": "生成台结果已回流到项目",
},
)
await event_bus.publish(
"asset.ready",
{
"job_id": link_id,
"path": outputs[0] if isinstance(outputs[0], str) else str(outputs[0]),
"project_id": ctx.project_id,
"target": "workbench",
"source": "playground_attach",
},
)
return {
"ok": True,
"already_attached": False,
"job_id": int(job["id"]),
"attached_job_id": link_id,
"project_id": ctx.project_id,
"stage": ctx.stage,
"target_id": ctx.source_id,
}
@@ -9,7 +9,7 @@ from calliope.models.schemas import Project, ProjectCreate, ProjectUpdate
router = APIRouter()
def _project_stats(project_id: int, conn) -> dict[str, int]:
def _project_stats(project_id: int, conn) -> dict:
cur = conn.execute(
"""
SELECT
@@ -18,11 +18,26 @@ def _project_stats(project_id: int, conn) -> dict[str, int]:
(SELECT COUNT(*) FROM characters WHERE project_id = :pid AND sheet_path IS NOT NULL)
+ (SELECT COUNT(*) FROM locations WHERE project_id = :pid AND reference_image_path IS NOT NULL) AS asset_ready_count,
(SELECT COUNT(*) FROM characters WHERE project_id = :pid)
+ (SELECT COUNT(*) FROM locations WHERE project_id = :pid) AS asset_total_count
+ (SELECT COUNT(*) FROM locations WHERE project_id = :pid) AS asset_total_count,
(SELECT COUNT(*) FROM jobs WHERE project_id = :pid
AND status IN ('pending','running')) AS active_jobs
""",
{"pid": project_id},
)
return dict(cur.fetchone())
stats = dict(cur.fetchone())
# Five-stage creation flow (HEL-614) — real per-stage states. Fresh
# projects have no rows yet, which reads as "not started" honestly.
try:
rows = conn.execute(
"SELECT stage_key, status FROM project_stages WHERE project_id = ?",
(project_id,),
).fetchall()
except Exception: # pragma: no cover — pre-migration DB
rows = []
stage_status = {r["stage_key"]: r["status"] for r in rows}
stats["stage_status"] = stage_status
stats["stage_confirmed"] = sum(1 for s in stage_status.values() if s == "confirmed")
return stats
@router.post("", response_model=Project)
@@ -0,0 +1,244 @@
"""Editable task deliverables and explicit, idempotent result → stage drafts."""
from __future__ import annotations
import json
from typing import Any
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field, model_validator
from calliope.config import settings
from calliope.db import get_db, row_to_dict
from calliope.events.bus import event_bus
from calliope.routers.workbench import StageKey
router = APIRouter()
class DraftPatch(BaseModel):
revision: int = Field(ge=1)
content: str = Field(min_length=1, max_length=200_000)
project_id: int | None = None
stage_key: StageKey
class StoreResult(BaseModel):
project_id: int = Field(gt=0)
stage_key: StageKey
deliverable_id: int | None = Field(default=None, gt=0)
revision: int | None = Field(default=None, gt=0)
job_id: int | None = Field(default=None, gt=0)
@model_validator(mode="after")
def one_source(self):
if (self.deliverable_id is None) == (self.job_id is None):
raise ValueError("Choose exactly one result source")
if self.deliverable_id is not None and self.revision is None:
raise ValueError("Deliverable revision is required")
return self
def _public(row) -> dict[str, Any]:
return row_to_dict(row)
@router.get("/deliverables")
def list_deliverables(session_id: int | None = None) -> list[dict[str, Any]]:
conn = get_db(settings.db_path)
try:
sql = "SELECT * FROM agent_deliverables"
args = ()
if session_id is not None:
sql += " WHERE session_id=?"
args = (session_id,)
return [_public(r) for r in conn.execute(sql + " ORDER BY id DESC", args)]
finally:
conn.close()
@router.get("/deliverables/{draft_id}/versions")
def list_versions(draft_id: int) -> list[dict[str, Any]]:
conn = get_db(settings.db_path)
try:
if not conn.execute("SELECT id FROM agent_deliverables WHERE id=?", (draft_id,)).fetchone():
raise HTTPException(404, "成果不存在")
return [
_public(row)
for row in conn.execute(
"SELECT * FROM agent_deliverable_versions WHERE deliverable_id=? "
"ORDER BY revision DESC",
(draft_id,),
)
]
finally:
conn.close()
@router.patch("/deliverables/{draft_id}")
def patch_deliverable(draft_id: int, payload: DraftPatch) -> dict[str, Any]:
conn = get_db(settings.db_path)
try:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute("SELECT * FROM agent_deliverables WHERE id=?", (draft_id,)).fetchone()
if not row:
raise HTTPException(404, "成果不存在")
if payload.project_id is not None:
from calliope.routers.workbench import _get_project
_get_project(conn, payload.project_id)
if row["revision"] != payload.revision:
raise HTTPException(409, "草稿已更新,请刷新后重试")
# A linked session cannot silently move its work into another project.
session = conn.execute(
"SELECT project_id FROM agent_sessions WHERE id=?", (row["session_id"],)
).fetchone()
if session["project_id"] is not None and payload.project_id != session["project_id"]:
raise HTTPException(409, "成果归属与原会话项目不一致")
if not payload.content.strip():
raise HTTPException(422, "成果内容不能为空")
conn.execute(
"INSERT INTO agent_deliverable_versions(deliverable_id,revision,content) "
"VALUES (?,?,?)",
(draft_id, row["revision"], row["content"]),
)
conn.execute(
"UPDATE agent_deliverables SET content=?,project_id=?,stage_key=?,revision=revision+1,"
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
(payload.content, payload.project_id, payload.stage_key, draft_id),
)
conn.commit()
return _public(
conn.execute("SELECT * FROM agent_deliverables WHERE id=?", (draft_id,)).fetchone()
)
finally:
conn.close()
@router.post("/store")
async def store_result(payload: StoreResult) -> dict[str, Any]:
from calliope.agent.llm import extract_json
from calliope.routers.playground import _assert_path_in_assets
from calliope.routers.workbench import (
NORMALIZERS,
StageContentError,
_get_project,
_stage_or_404,
_stage_public,
)
conn = get_db(settings.db_path)
try:
conn.execute("BEGIN IMMEDIATE")
_get_project(conn, payload.project_id)
stage = _stage_or_404(conn, payload.project_id, payload.stage_key)
draft = None
if payload.deliverable_id is not None:
draft = conn.execute(
"SELECT * FROM agent_deliverables WHERE id=?", (payload.deliverable_id,)
).fetchone()
if not draft:
raise HTTPException(404, "成果不存在")
if draft["revision"] != payload.revision:
raise HTTPException(409, "草稿版本已变化,请先保存并刷新")
if draft["project_id"] != payload.project_id or draft["stage_key"] != payload.stage_key:
raise HTTPException(409, "请先保存成果的项目与阶段归属")
session = conn.execute(
"SELECT project_id FROM agent_sessions WHERE id=?", (draft["session_id"],)
).fetchone()
if session["project_id"] is not None and session["project_id"] != payload.project_id:
raise HTTPException(409, "会话项目已变化,请重新核对成果归属")
source_key = f"deliverable:{draft['id']}:{draft['revision']}"
result = {
"source_key": source_key,
"title": draft["title"],
"text": draft["content"],
"session_id": draft["session_id"],
}
else:
job = conn.execute("SELECT * FROM jobs WHERE id=?", (payload.job_id,)).fetchone()
if not job:
raise HTTPException(404, "生成结果不存在")
project = conn.execute(
"SELECT status FROM projects WHERE id=?", (job["project_id"],)
).fetchone()
if job["project_id"] != payload.project_id and (
not project or project["status"] != "system"
):
raise HTTPException(409, "此结果属于其他项目")
if job["status"] != "done" or job["kind"] not in ("image", "video"):
raise HTTPException(409, "仅可存入已完成的图片或视频")
paths = json.loads(job["output_paths_json"] or "[]")
if (
not paths
or not isinstance(paths, list)
or not all(isinstance(p, str) for p in paths)
):
raise HTTPException(422, "结果没有产物")
for path in paths:
if not _assert_path_in_assets(path).is_file():
raise HTTPException(410, "产物文件已不存在")
source_key = f"job:{job['id']}"
result = {
"source_key": source_key,
"title": f"生成结果 #{job['id']}",
"kind": job["kind"],
"paths": paths,
"job_id": job["id"],
}
existing = conn.execute(
"SELECT id FROM result_attachments WHERE source_key=? AND project_id=? AND stage_key=?",
(source_key, payload.project_id, payload.stage_key),
).fetchone()
if existing:
return {"ok": True, "already_attached": True, "stage": _stage_public(stage)}
if stage["status"] == "running":
raise HTTPException(409, "本阶段正在生成,请稍后再存入")
content = json.loads(stage["content_json"] or "{}")
if draft:
text = draft["content"].strip()
# Valid structured artifacts replace only the working draft.
# Confirmed snapshots stay intact.
parsed = None
try:
parsed = extract_json(text)
except ValueError:
if payload.stage_key == "script":
parsed = {"text": text}
if isinstance(parsed, dict):
try:
normalized = NORMALIZERS[payload.stage_key](parsed)
normalized["incoming_results"] = content.get("incoming_results", [])
content = normalized
except StageContentError as exc:
raise HTTPException(422, str(exc)) from exc
incoming = list(content.get("incoming_results") or [])
incoming.append(result)
content["incoming_results"] = incoming
conn.execute(
"UPDATE project_stages SET content_json=?,status='awaiting_confirm',draft_edited=1,"
"error=NULL,updated_at=CURRENT_TIMESTAMP WHERE project_id=? AND stage_key=?",
(json.dumps(content, ensure_ascii=False), payload.project_id, payload.stage_key),
)
conn.execute(
"INSERT INTO result_attachments(source_key,project_id,stage_key) VALUES (?,?,?)",
(source_key, payload.project_id, payload.stage_key),
)
if draft:
conn.execute(
"UPDATE agent_sessions SET task_status='returned' WHERE id=?",
(draft["session_id"],),
)
conn.execute(
"UPDATE projects SET updated_at=CURRENT_TIMESTAMP WHERE id=?", (payload.project_id,)
)
conn.commit()
row = _stage_or_404(conn, payload.project_id, payload.stage_key)
out = {"ok": True, "already_attached": False, "stage": _stage_public(row)}
finally:
conn.close()
await event_bus.publish(
"workbench.stage.updated",
{"project_id": payload.project_id, "stage_key": payload.stage_key},
)
return out
@@ -107,7 +107,11 @@ async def update_settings(payload: SettingsUpdate) -> dict[str, Any]:
if assignments_in is not None:
settings.ensure_llm_profiles()
ids = {p["id"] for p in settings.llm_profiles}
cleaned: dict[str, str | None] = {}
# HEL-619: per-role INCREMENTAL update. Rapid dropdown switches used
# to send whole cached snapshots that fully overwrote the map, so a
# stale snapshot could wipe a newer choice. Roles absent from the
# payload keep their stored value; a role sent as null is cleared.
cleaned: dict[str, str | None] = dict(settings.agent_llm_assignments or {})
for role, pid in assignments_in.items():
if role not in AGENT_LLM_ROLES:
continue
@@ -0,0 +1,968 @@
"""Five-stage creation workbench (HEL-614).
剧本创作 → 视觉设计 → 分镜设计 → 素材资产 → 镜头脚本 — one project, five
named agents in sequence. Each stage holds a working draft the user can edit
(``content_json``) and the last confirmed snapshot
(``confirmed_content_json``); only confirmed snapshots flow into the next
agent's context, so an unconfirmed draft is never treated as final.
Stage runs reuse the HEL-613 registry (persona + llm_role + skill bindings)
with a workbench-specific mode prompt and a per-stage JSON artifact schema.
In-flow generation reuses the HEL-612 shared queue: jobs are project-linked
with ``payload.source = "workbench"`` so tasks and results come back to the
project (and the tasks page) like every other job.
"""
from __future__ import annotations
import asyncio
import json
import re
from typing import Any, Literal
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from calliope import config
from calliope.agent.agents_registry import AGENTS, AGENT_KEYS
from calliope.agent.llm import LLMClient, extract_json
from calliope.agent.skills_store import effective_skill_dirs, list_skills
from calliope.comfyui.parser import parse_dynamic_inputs
from calliope.comfyui.smart_fill import smart_fill_inputs
from calliope.db import get_db, row_to_dict
from calliope.events.bus import event_bus
from calliope.queue.manager import queue_manager
router = APIRouter()
STAGE_KEYS: tuple[str, ...] = AGENT_KEYS # script, style, storyboard, assets, shot
StageKey = Literal["script", "style", "storyboard", "assets", "shot"]
WORKBENCH_SOURCE = "workbench"
class StagePatch(BaseModel):
content: dict[str, Any]
chosen_style_id: str | None = None
class StageNotePatch(BaseModel):
text: str = Field(max_length=200_000)
class StageRunRequest(BaseModel):
instruction: str | None = Field(default=None, max_length=8000)
class StageConfirmRequest(BaseModel):
chosen_style_id: str | None = None
class ScriptImportRequest(BaseModel):
text: str = Field(min_length=1, max_length=200_000)
class WorkbenchGenerateRequest(BaseModel):
kind: Literal["image", "video"]
stage_key: StageKey
target_id: str | None = Field(default=None, max_length=64)
prompt: str = Field(min_length=1, max_length=8000)
negative_prompt: str | None = Field(default=None, max_length=2000)
duration_sec: float | None = None
# ─────────────────────────────────────────────────────────────────────────
# Content normalization — one defensive normalizer per stage. Accepts mild
# LLM shape drift, fills defaults, rejects unusable output with a Chinese
# reason that surfaces on the run row.
# ─────────────────────────────────────────────────────────────────────────
class StageContentError(ValueError):
"""Unusable artifact content; str(err) is user-facing Chinese."""
def _text(value: Any) -> str:
return str(value if value is not None else "").strip()
def _num(value: Any, default: float) -> float:
try:
out = float(value)
except (TypeError, ValueError):
return default
return out if out > 0 else default
def _slug_id(prefix: str, index: int) -> str:
return f"{prefix}{index + 1}"
def _norm_script(data: dict[str, Any]) -> dict[str, Any]:
text = _text(data.get("text") or data.get("script") or data.get("content"))
if len(text) < 30:
raise StageContentError("剧本内容太短(不足 30 字),请重试或在下方手动补充。")
return {"text": text}
def _norm_style(data: dict[str, Any]) -> dict[str, Any]:
raw = data.get("directions") or data.get("styles") or []
if not isinstance(raw, list):
raise StageContentError("风格数据格式不正确,缺少风格方向列表。")
directions = []
used_ids: set[str] = set()
for i, item in enumerate(raw):
if not isinstance(item, dict):
continue
name = _text(item.get("name") or item.get("title"))
prompt = _text(item.get("prompt") or item.get("keywords"))
if not name or not prompt:
continue
sid = _text(item.get("id"))
if not sid or sid in used_ids:
sid = _slug_id("style", i)
n = 2
while sid in used_ids:
sid = f"style{i + 1}-{n}"
n += 1
used_ids.add(sid)
directions.append(
{
"id": sid,
"name": name,
"description": _text(item.get("description")),
"prompt": prompt,
"negative_prompt": _text(item.get("negative_prompt")),
}
)
if len(directions) < 2:
raise StageContentError("风格方向不足 2 套。请让智能体重试,或手动补充更多方向。")
return {"directions": directions}
def _norm_storyboard(data: dict[str, Any]) -> dict[str, Any]:
raw = data.get("shots") or data.get("storyboard") or []
if not isinstance(raw, list):
raise StageContentError("分镜数据格式不正确,缺少逐镜列表。")
shots = []
for i, item in enumerate(raw):
if not isinstance(item, dict):
continue
description = _text(item.get("description") or item.get("action") or item.get("content"))
if not description:
continue
shots.append(
{
"id": _text(item.get("id")) or _slug_id("shot", i),
"scene": _text(item.get("scene")) or f"{i + 1}",
"shot_size": _text(item.get("shot_size") or item.get("size")) or "中景",
"camera": _text(item.get("camera") or item.get("movement")) or "固定",
"description": description,
"dialog": _text(item.get("dialog") or item.get("dialogue")),
"duration_sec": _num(item.get("duration_sec") or item.get("duration"), 3.0),
}
)
if not shots:
raise StageContentError("分镜表为空。请让智能体重试,或手动添加镜头。")
return {"shots": shots}
def _norm_assets(data: dict[str, Any]) -> dict[str, Any]:
raw = data.get("assets") or data.get("items") or []
if not isinstance(raw, list):
raise StageContentError("素材数据格式不正确,缺少资产列表。")
assets = []
for i, item in enumerate(raw):
if not isinstance(item, dict):
continue
name = _text(item.get("name") or item.get("title"))
prompt = _text(item.get("prompt"))
if not name or not prompt:
continue
kind = _text(item.get("kind") or item.get("type"))
if kind not in ("character", "location", "prop"):
kind = "prop"
assets.append(
{
"id": _text(item.get("id")) or _slug_id("asset", i),
"kind": kind,
"name": name,
"description": _text(item.get("description")),
"prompt": prompt,
"negative_prompt": _text(item.get("negative_prompt")),
}
)
if not assets:
raise StageContentError("素材清单为空。请让智能体重试,或手动添加素材。")
return {"assets": assets}
def _norm_shot(data: dict[str, Any]) -> dict[str, Any]:
raw = data.get("shots") or []
if not isinstance(raw, list):
raise StageContentError("镜头脚本数据格式不正确,缺少逐镜列表。")
shots = []
for i, item in enumerate(raw):
if not isinstance(item, dict):
continue
prompt = _text(item.get("prompt") or item.get("video_prompt"))
if not prompt:
continue
shots.append(
{
"id": _text(item.get("id")) or _slug_id("vshot", i),
"shot_no": _text(item.get("shot_no") or item.get("shot")) or _slug_id("S", i),
"description": _text(item.get("description")),
"prompt": prompt,
"duration_sec": _num(item.get("duration_sec") or item.get("duration"), 5.0),
}
)
if not shots:
raise StageContentError("镜头脚本为空。请让智能体重试,或手动添加镜头。")
return {"shots": shots}
NORMALIZERS = {
"script": _norm_script,
"style": _norm_style,
"storyboard": _norm_storyboard,
"assets": _norm_assets,
"shot": _norm_shot,
}
# Short artifact brief embedded in each run prompt (Chinese; keeps the five
# agents' deliverables aligned with what the workbench UI renders).
STAGE_BRIEFS = {
"script": (
"产出完整可拍摄的短视频剧本:故事大纲、按场次展开的分场(场景/时间/地点/人物)、"
"人物小传要点、逐场台词与动作描述。直接输出剧本正文,不要写「好的」之类的客套话。"
),
"style": (
"给出至少 2 套(建议 3 套)差异化视觉风格方向,每套包含:风格名称、中文风格说明"
"(色调/光影/材质/线条)、可直接用于生图模型的英文正向提示词词组(逗号分隔)、"
"可选的英文负面提示词。提示词词组必须完整可复制。"
),
"storyboard": (
"把剧本拆成逐镜分镜表,每镜包含:场次、景别(远/全/中/近/特写)、运镜"
"(推/拉/摇/移/跟/固定)、画面内容描述、台词或音效起点、建议时长(秒)。"
"重要情绪点给特写,节奏有起伏。"
),
"assets": (
"结合剧本、已确认的视觉风格与分镜表,整理需要生图的资产清单(角色/场景/道具),"
"每项资产给出:类型、名称、中文说明、完整可直接复制的英文生图提示词"
"(主体外观细节+风格关键词+构图视角+画质词)、可选负面提示词。"
"同一角色要保持跨视角一致性。"
),
"shot": (
"把分镜表转化为视频生成模型(如 MiniMax H3)可用的逐镜脚本,每镜包含:镜号、"
"中文画面说明、完整可直接复制的英文视频提示词(主体与动作+运镜指令+镜头语言+"
"光影氛围)、建议时长(秒)。同一项目运镜与风格保持一致。"
),
}
def _stage_name(stage_key: str) -> str:
return AGENTS[stage_key]["name"] if stage_key in AGENTS else stage_key
# ─────────────────────────────────────────────────────────────────────────
# DB helpers
# ─────────────────────────────────────────────────────────────────────────
def _get_project(conn, project_id: int) -> dict[str, Any]:
row = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone()
if not row or row["status"] == "system":
raise HTTPException(status_code=404, detail="项目不存在")
return row_to_dict(row)
def _ensure_stage_rows(conn, project_id: int) -> None:
for key in STAGE_KEYS:
conn.execute(
"INSERT OR IGNORE INTO project_stages (project_id, stage_key) VALUES (?, ?)",
(project_id, key),
)
conn.commit()
def _load_stage(conn, project_id: int, stage_key: str) -> dict[str, Any]:
row = conn.execute(
"SELECT * FROM project_stages WHERE project_id = ? AND stage_key = ?",
(project_id, stage_key),
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="阶段不存在")
return row_to_dict(row)
def _stage_public(row: dict[str, Any]) -> dict[str, Any]:
content = json.loads(row["content_json"]) if row["content_json"] else None
return {
"stage_key": row["stage_key"],
"status": row["status"],
"content": content,
"assistant_note": (content or {}).get("assistant_note", ""),
"incoming_results": (content or {}).get("incoming_results", []),
"confirmed_content": (
json.loads(row["confirmed_content_json"]) if row["confirmed_content_json"] else None
),
"chosen_style_id": row["chosen_style_id"],
"draft_edited": bool(row["draft_edited"]),
"error": row["error"],
"updated_at": row["updated_at"],
}
def _last_run(conn, project_id: int, stage_key: str) -> dict[str, Any] | None:
row = conn.execute(
"SELECT * FROM stage_runs WHERE project_id = ? AND stage_key = ? "
"ORDER BY id DESC LIMIT 1",
(project_id, stage_key),
).fetchone()
if not row:
return None
return {
"id": row["id"],
"status": row["status"],
"error": row["error"],
"instruction": row["instruction"],
"created_at": row["created_at"],
"completed_at": row["completed_at"],
}
def _job_public(job: dict[str, Any]) -> dict[str, Any]:
out = dict(job)
out["payload"] = json.loads(job.get("payload_json") or "{}")
out["output_paths"] = json.loads(job.get("output_paths_json") or "[]")
out.pop("payload_json", None)
out.pop("output_paths_json", None)
return out
def _workbench_job_outputs(conn, project_id: int) -> dict[str, dict[str, Any]]:
"""target_id → latest workbench job (any status) for asset/shot panels."""
rows = conn.execute(
"SELECT * FROM jobs WHERE project_id = ? ORDER BY id ASC",
(project_id,),
).fetchall()
out: dict[str, dict[str, Any]] = {}
for row in rows:
job = _job_public(row_to_dict(row))
payload = job.get("payload") or {}
if payload.get("source") != WORKBENCH_SOURCE:
continue
target = payload.get("target_id")
if not target:
continue
prev = out.get(target)
# later rows win; keep the newest per target
if prev is None or int(job["id"]) > int(prev["id"]):
out[target] = job
return out
def _enrich_stage(
stage: dict[str, Any], outputs: dict[str, dict[str, Any]]
) -> dict[str, Any]:
content = stage.get("content") or {}
if stage["stage_key"] == "assets" and isinstance(content.get("assets"), list):
for asset in content["assets"]:
job = outputs.get(asset.get("id"))
if job:
asset["job"] = _job_brief(job)
elif stage["stage_key"] == "shot" and isinstance(content.get("shots"), list):
for shot in content["shots"]:
job = outputs.get(shot.get("id"))
if job:
shot["job"] = _job_brief(job)
return stage
def _job_brief(job: dict[str, Any]) -> dict[str, Any]:
return {
"id": job["id"],
"status": job["status"],
"kind": job["kind"],
"output": (job.get("output_paths") or [None])[0],
"error": job.get("error"),
}
# ─────────────────────────────────────────────────────────────────────────
# Stage-run prompt assembly + background execution
# ─────────────────────────────────────────────────────────────────────────
_SKILL_BODY_MAX_CHARS = 1600
_SKILL_BODY_MAX_COUNT = 3
def _skills_context(agent_key: str) -> str:
try:
effective = effective_skill_dirs(agent_key)
skills = [s for s in list_skills(cap=None) if s["dir"] in effective]
except Exception:
return ""
if not skills:
return ""
lines = ["以下 Skill 已分配给该智能体(用户配置),生成时请吸收其要点:"]
shown = 0
for skill in skills:
lines.append(f"- {skill['name']}: {skill['description']}")
if shown >= _SKILL_BODY_MAX_COUNT:
continue
body = _skill_body(skill["dir"])
if body:
lines.append(f" {skill['name']} 内容摘要:\n{body}")
shown += 1
return "\n".join(lines)
def _skill_body(skill_dir: str) -> str:
from calliope.agent.skills_store import skills_root
path = skills_root() / skill_dir / "SKILL.md"
try:
text = path.read_text(encoding="utf-8")
except OSError:
return ""
text = re.sub(r"^---.*?---\s*", "", text, flags=re.S)
text = text.strip()
if len(text) > _SKILL_BODY_MAX_CHARS:
text = text[:_SKILL_BODY_MAX_CHARS] + "…"
return text
def _system_prompt(stage_key: str) -> str:
agent = AGENTS[stage_key]
idx = STAGE_KEYS.index(stage_key) + 1
parts = [
agent["persona"],
(
f"WORKBENCH MODE: 你正在「创作工作台」五段流程中负责第 {idx} 段「{agent['name']}」。"
"本次输出会被程序解析入库并在工作台展示给用户编辑、确认,因此:\n"
"- 只输出一个 JSON 对象,不要加 Markdown 代码围栏,不要输出 JSON 以外的任何文字。\n"
"- 严格遵守用户消息末尾给出的 JSON 结构与字段要求,中文内容用中文写,"
"提示词词组按各段惯例(英文逗号分隔)。\n"
"- 这是正式交付物:内容要完整、具体、可直接使用,不要输出占位符或「待补充」。"
),
f"本段任务:{STAGE_BRIEFS[stage_key]}",
]
skills_ctx = _skills_context(stage_key)
if skills_ctx:
parts.append(skills_ctx)
return "\n\n".join(parts)
def _stage_context(conn, project_id: int, stage_key: str) -> str:
"""Confirmed upstream artifacts only — drafts never leak downstream."""
project = _get_project(conn, project_id)
blocks: list[str] = [f"项目名称:{project['title']}"]
if project.get("idea") and stage_key != "script":
idea = _text(project["idea"])
if len(idea) <= 600:
blocks.append(f"用户创意:{idea}")
rows = conn.execute(
"SELECT * FROM project_stages WHERE project_id = ?",
(project_id,),
).fetchall()
stages = {r["stage_key"]: row_to_dict(r) for r in rows}
def confirmed(key: str) -> dict[str, Any] | None:
row = stages.get(key)
if not row or row["status"] != "confirmed" or not row["confirmed_content_json"]:
return None
return json.loads(row["confirmed_content_json"])
script = confirmed("script")
if script and stage_key in ("style", "storyboard", "assets", "shot"):
blocks.append(f"【已确认的剧本】\n{_text(script.get('text'))}")
style = confirmed("style")
if style and stage_key in ("storyboard", "assets", "shot"):
chosen_id = stages.get("style", {}).get("chosen_style_id")
directions = style.get("directions") or []
chosen = next(
(d for d in directions if d.get("id") == chosen_id), directions[0] if directions else None
)
if chosen:
blocks.append(
"【已确认的视觉风格】\n"
f"{chosen.get('name')}:{chosen.get('description') or ''}\n"
f"风格关键词:{chosen.get('prompt')}"
)
else:
names = "、".join(d.get("name", "") for d in directions)
blocks.append(f"【已确认的视觉风格】候选方向:{names}")
board = confirmed("storyboard")
if board and stage_key in ("assets", "shot"):
lines = []
for i, shot in enumerate(board.get("shots") or [], 1):
lines.append(
f"{i}. 场次{shot.get('scene')}|{shot.get('shot_size')}|{shot.get('camera')}|"
f"{shot.get('description')}"
+ (f"|台词:{shot.get('dialog')}" if shot.get("dialog") else "")
)
blocks.append("【已确认的分镜表】\n" + "\n".join(lines))
assets = confirmed("assets")
if assets and stage_key == "shot":
lines = [
f"- {a.get('kind')}|{a.get('name')}:{a.get('description') or a.get('prompt')}"
for a in assets.get("assets") or []
]
blocks.append("【已确认的素材清单】\n" + "\n".join(lines))
return "\n\n".join(blocks)
_JSON_SCHEMAS = {
"script": '{"text": "完整剧本正文(简体中文,含大纲/分场/台词)"}',
"style": (
'{"directions": [{"id": "style1", "name": "风格名称", '
'"description": "中文风格说明", "prompt": "english, style, keywords", '
'"negative_prompt": "optional english negative keywords"}]}(至少 2 套)'
),
"storyboard": (
'{"shots": [{"id": "shot1", "scene": "场次", "shot_size": "景别", '
'"camera": "运镜", "description": "画面内容", "dialog": "台词(可空)", '
'"duration_sec": 3}]}'
),
"assets": (
'{"assets": [{"id": "asset1", "kind": "character|location|prop", '
'"name": "名称", "description": "中文说明", '
'"prompt": "complete english image prompt", '
'"negative_prompt": "可空"}]}'
),
"shot": (
'{"shots": [{"id": "vshot1", "shot_no": "S1", '
'"description": "中文画面说明", "prompt": "complete english video prompt", '
'"duration_sec": 5}]}'
),
}
# Strong refs so in-flight runs are never garbage-collected mid-turn
# (same pattern as the harness runner).
_ACTIVE_RUNS: dict[int, asyncio.Task] = {}
async def _execute_run(project_id: int, stage_key: str, run_id: int, instruction: str | None) -> None:
db = config.settings.db_path
try:
conn = get_db(db)
try:
context = _stage_context(conn, project_id, stage_key)
finally:
conn.close()
user_parts = [context]
if instruction and instruction.strip():
user_parts.append(f"用户补充要求:{instruction.strip()}")
user_parts.append(
f"请产出本段({ _stage_name(stage_key)})的 JSON:{_JSON_SCHEMAS[stage_key]}"
)
messages = [
{"role": "system", "content": _system_prompt(stage_key)},
{"role": "user", "content": "\n\n".join(user_parts)},
]
role = AGENTS[stage_key]["llm_role"]
client = LLMClient.for_role(role, timeout=240.0)
try:
text = await client.chat(messages, temperature=0.7)
finally:
await client.close()
content = NORMALIZERS[stage_key](extract_json(text))
conn = get_db(db)
try:
latest_stage = _load_stage(conn, project_id, stage_key)
previous = json.loads(latest_stage["content_json"]) if latest_stage["content_json"] else {}
if previous.get("assistant_note"):
content["assistant_note"] = previous["assistant_note"]
conn.execute(
"UPDATE stage_runs SET status='done', output_json=?, completed_at=CURRENT_TIMESTAMP "
"WHERE id = ?",
(json.dumps(content, ensure_ascii=False), run_id),
)
conn.execute(
"UPDATE project_stages SET content_json=?, status='awaiting_confirm', "
"draft_edited=0, error=NULL, updated_at=CURRENT_TIMESTAMP "
"WHERE project_id = ? AND stage_key = ?",
(json.dumps(content, ensure_ascii=False), project_id, stage_key),
)
conn.execute(
"UPDATE projects SET updated_at=CURRENT_TIMESTAMP WHERE id = ?",
(project_id,),
)
conn.commit()
finally:
conn.close()
except Exception as exc: # noqa: BLE001 — any failure becomes a readable run row
reason = _friendly_error(exc)
try:
conn = get_db(db)
try:
conn.execute(
"UPDATE stage_runs SET status='failed', error=?, completed_at=CURRENT_TIMESTAMP "
"WHERE id = ?",
(reason, run_id),
)
conn.execute(
"UPDATE project_stages SET status=CASE "
"WHEN confirmed_content_json IS NOT NULL THEN 'confirmed' ELSE 'todo' END, "
"error=?, updated_at=CURRENT_TIMESTAMP "
"WHERE project_id = ? AND stage_key = ? AND status = 'running'",
(reason, project_id, stage_key),
)
conn.commit()
finally:
conn.close()
except Exception: # pragma: no cover — bookkeeping failure only
pass
def _friendly_error(exc: Exception) -> str:
text = str(exc)
lowered = text.lower()
if "connect" in lowered or "timeout" in lowered or "timed out" in lowered:
return (
"连接大模型服务失败:请到「模型设置」检查地址与密钥,或点「重新生成」再试一次。"
)
if "no json" in lowered or isinstance(exc, ValueError):
return "智能体没有返回可解析的结果,请点「重新生成」重试;多次失败可换一个模型。"
return f"生成失败:{text[:300]}"
# ─────────────────────────────────────────────────────────────────────────
# Endpoints
# ─────────────────────────────────────────────────────────────────────────
@router.get("/projects/{project_id}")
async def workbench_overview(project_id: int) -> dict[str, Any]:
conn = get_db(config.settings.db_path)
try:
project = _get_project(conn, project_id)
_ensure_stage_rows(conn, project_id)
rows = conn.execute(
"SELECT * FROM project_stages WHERE project_id = ? ORDER BY id ASC",
(project_id,),
).fetchall()
outputs = _workbench_job_outputs(conn, project_id)
stages = []
for row in rows:
stage = _stage_public(row_to_dict(row))
stages.append(_enrich_stage(stage, outputs))
runs = {key: _last_run(conn, project_id, key) for key in STAGE_KEYS}
for stage in stages:
stage["last_run"] = runs.get(stage["stage_key"])
return {
"project": {
"id": project["id"],
"title": project["title"],
"idea": project.get("idea"),
"status": project["status"],
"updated_at": project["updated_at"],
},
"stages": stages,
}
finally:
conn.close()
def _stage_or_404(conn, project_id: int, stage_key: str) -> dict[str, Any]:
if stage_key not in STAGE_KEYS:
raise HTTPException(status_code=404, detail="阶段不存在")
_ensure_stage_rows(conn, project_id)
return _load_stage(conn, project_id, stage_key)
@router.patch("/projects/{project_id}/stages/{stage_key}")
async def patch_stage(project_id: int, stage_key: str, payload: StagePatch) -> dict[str, Any]:
conn = get_db(config.settings.db_path)
try:
_get_project(conn, project_id)
stage = _stage_or_404(conn, project_id, stage_key)
if stage["status"] == "running":
raise HTTPException(status_code=409, detail="本阶段正在生成中,请稍候再编辑。")
try:
content = NORMALIZERS[stage_key](payload.content)
except StageContentError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
old_content = json.loads(stage["content_json"]) if stage["content_json"] else {}
if old_content.get("incoming_results"):
content["incoming_results"] = old_content["incoming_results"]
if old_content.get("assistant_note"):
content["assistant_note"] = old_content["assistant_note"]
if content == (json.loads(stage["content_json"]) if stage["content_json"] else None):
return {"ok": True, "stage": _stage_public({**stage, "content_json": json.dumps(content, ensure_ascii=False)})}
# Editing a confirmed stage produces a new draft awaiting re-confirm;
# the confirmed snapshot stays intact for 恢复.
next_status = "awaiting_confirm" if stage["status"] == "confirmed" else stage["status"]
if stage["status"] == "todo":
next_status = "awaiting_confirm"
chosen = payload.chosen_style_id
if stage_key == "style" and chosen:
ids = [d.get("id") for d in content.get("directions", [])]
if chosen not in ids:
raise HTTPException(status_code=400, detail="所选风格方向不存在,请刷新后重试。")
conn.execute(
"UPDATE project_stages SET content_json=?, status=?, draft_edited=1, "
"chosen_style_id=COALESCE(?, chosen_style_id), updated_at=CURRENT_TIMESTAMP "
"WHERE project_id = ? AND stage_key = ?",
(
json.dumps(content, ensure_ascii=False),
next_status,
chosen,
project_id,
stage_key,
),
)
conn.execute(
"UPDATE projects SET updated_at=CURRENT_TIMESTAMP WHERE id = ?", (project_id,)
)
conn.commit()
row = _load_stage(conn, project_id, stage_key)
return {"ok": True, "stage": _stage_public(row)}
finally:
conn.close()
@router.patch("/projects/{project_id}/stages/{stage_key}/note")
async def patch_stage_note(project_id: int, stage_key: str, payload: StageNotePatch) -> dict[str, Any]:
"""Keep free-text advice separate from validated stage artifacts and snapshots."""
conn = get_db(config.settings.db_path)
try:
_get_project(conn, project_id)
stage = _stage_or_404(conn, project_id, stage_key)
if stage["status"] == "running":
raise HTTPException(status_code=409, detail="本阶段正在生成中,请稍候再保存建议。")
content = json.loads(stage["content_json"]) if stage["content_json"] else {}
if payload.text.strip():
content["assistant_note"] = payload.text.strip()
else:
content.pop("assistant_note", None)
conn.execute(
"UPDATE project_stages SET content_json=?, updated_at=CURRENT_TIMESTAMP "
"WHERE project_id=? AND stage_key=?",
(json.dumps(content, ensure_ascii=False) if content else None, project_id, stage_key),
)
conn.execute("UPDATE projects SET updated_at=CURRENT_TIMESTAMP WHERE id=?", (project_id,))
conn.commit()
return {"ok": True, "stage": _stage_public(_load_stage(conn, project_id, stage_key))}
finally:
conn.close()
@router.post("/projects/{project_id}/stages/{stage_key}/run")
async def run_stage(project_id: int, stage_key: str, payload: StageRunRequest | None = None) -> dict[str, Any]:
instruction = (payload.instruction if payload else None) or None
conn = get_db(config.settings.db_path)
try:
_get_project(conn, project_id)
stage = _stage_or_404(conn, project_id, stage_key)
if stage["status"] == "running":
raise HTTPException(status_code=409, detail="本阶段正在生成中,请稍候。")
# Prerequisite: the previous stage must be confirmed (drafts never
# unlock the next agent).
idx = STAGE_KEYS.index(stage_key)
if idx > 0:
prev = _load_stage(conn, project_id, STAGE_KEYS[idx - 1])
if prev["status"] != "confirmed":
raise HTTPException(
status_code=409,
detail=f"请先完成并确认上一阶段「{_stage_name(STAGE_KEYS[idx - 1])}」。",
)
# LLM must be resolvable — fail fast with a fix-it message.
try:
profile = config.settings.resolve_llm_for_role(AGENTS[stage_key]["llm_role"])
except Exception: # pragma: no cover — defensive
profile = None
if not profile or not (profile.get("base_url") or "").strip():
raise HTTPException(
status_code=400,
detail="尚未配置可用的大模型:请到「模型设置」添加并测试通过后再生成。",
)
cur = conn.execute(
"INSERT INTO stage_runs (project_id, stage_key, instruction) VALUES (?, ?, ?)",
(project_id, stage_key, instruction),
)
run_id = int(cur.lastrowid)
conn.execute(
"UPDATE project_stages SET status='running', error=NULL, updated_at=CURRENT_TIMESTAMP "
"WHERE project_id = ? AND stage_key = ?",
(project_id, stage_key),
)
conn.commit()
finally:
conn.close()
task = asyncio.create_task(_execute_run(project_id, stage_key, run_id, instruction))
_ACTIVE_RUNS[run_id] = task
task.add_done_callback(lambda t: _ACTIVE_RUNS.pop(run_id, None))
return {"ok": True, "run_id": run_id}
@router.post("/projects/{project_id}/stages/{stage_key}/confirm")
async def confirm_stage(
project_id: int, stage_key: str, payload: StageConfirmRequest | None = None
) -> dict[str, Any]:
conn = get_db(config.settings.db_path)
try:
_get_project(conn, project_id)
stage = _stage_or_404(conn, project_id, stage_key)
if stage["status"] == "running":
raise HTTPException(status_code=409, detail="本阶段正在生成中,请等待完成。")
if not stage["content_json"]:
raise HTTPException(status_code=400, detail="本阶段还没有内容,请先生成或手动填写。")
try:
content = NORMALIZERS[stage_key](json.loads(stage["content_json"]))
except StageContentError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
raw_content = json.loads(stage["content_json"])
if raw_content.get("incoming_results"):
content["incoming_results"] = raw_content["incoming_results"]
chosen = payload.chosen_style_id if payload else None
if stage_key == "style":
directions = content.get("directions") or []
ids = [d.get("id") for d in directions]
if chosen and chosen not in ids:
raise HTTPException(status_code=400, detail="所选风格方向不存在,请刷新后重试。")
if not chosen:
chosen = stage["chosen_style_id"] or (ids[0] if ids else None)
conn.execute(
"UPDATE project_stages SET status='confirmed', confirmed_content_json=?, "
"chosen_style_id=?, draft_edited=0, error=NULL, updated_at=CURRENT_TIMESTAMP "
"WHERE project_id = ? AND stage_key = ?",
(
json.dumps(content, ensure_ascii=False),
chosen if stage_key == "style" else stage["chosen_style_id"],
project_id,
stage_key,
),
)
conn.execute(
"UPDATE projects SET updated_at=CURRENT_TIMESTAMP WHERE id = ?", (project_id,)
)
conn.commit()
row = _load_stage(conn, project_id, stage_key)
return {"ok": True, "stage": _stage_public(row)}
finally:
conn.close()
@router.post("/projects/{project_id}/stages/{stage_key}/restore")
async def restore_stage(project_id: int, stage_key: str) -> dict[str, Any]:
conn = get_db(config.settings.db_path)
try:
_get_project(conn, project_id)
stage = _stage_or_404(conn, project_id, stage_key)
if not stage["confirmed_content_json"]:
raise HTTPException(status_code=400, detail="本阶段还没有已确认版本,无需恢复。")
content = json.loads(stage["confirmed_content_json"])
current = json.loads(stage["content_json"]) if stage["content_json"] else {}
if current.get("assistant_note"):
content["assistant_note"] = current["assistant_note"]
conn.execute(
"UPDATE project_stages SET content_json=?, "
"status='confirmed', draft_edited=0, updated_at=CURRENT_TIMESTAMP "
"WHERE project_id = ? AND stage_key = ?",
(json.dumps(content, ensure_ascii=False), project_id, stage_key),
)
conn.commit()
row = _load_stage(conn, project_id, stage_key)
return {"ok": True, "stage": _stage_public(row)}
finally:
conn.close()
@router.post("/projects/{project_id}/stages/script/import")
async def import_script(project_id: int, payload: ScriptImportRequest) -> dict[str, Any]:
"""导入现成剧本:直接确认为剧本阶段基线,从视觉设计继续。"""
text = payload.text.strip()
if len(text) < 30:
raise HTTPException(status_code=400, detail="剧本内容太短,请检查粘贴是否完整。")
content = {"text": text}
conn = get_db(config.settings.db_path)
try:
_get_project(conn, project_id)
_stage_or_404(conn, project_id, "script")
conn.execute(
"UPDATE project_stages SET content_json=?, confirmed_content_json=?, "
"status='confirmed', draft_edited=0, error=NULL, updated_at=CURRENT_TIMESTAMP "
"WHERE project_id = ? AND stage_key = 'script'",
(
json.dumps(content, ensure_ascii=False),
json.dumps(content, ensure_ascii=False),
project_id,
),
)
conn.execute(
"UPDATE projects SET updated_at=CURRENT_TIMESTAMP WHERE id = ?", (project_id,)
)
conn.commit()
row = _load_stage(conn, project_id, "script")
return {"ok": True, "stage": _stage_public(row)}
finally:
conn.close()
@router.post("/projects/{project_id}/generate")
async def workbench_generate(project_id: int, payload: WorkbenchGenerateRequest) -> dict[str, Any]:
"""In-flow generation (HEL-612 shared queue): one image or video job,
project-linked, tagged workbench so results come back to the project."""
conn = get_db(config.settings.db_path)
try:
_get_project(conn, project_id)
_stage_or_404(conn, project_id, payload.stage_key)
wf = conn.execute(
"SELECT * FROM workflows WHERE kind = ? AND is_enabled = 1 ORDER BY id ASC LIMIT 1",
(payload.kind,),
).fetchone()
if not wf:
label = "生图" if payload.kind == "image" else "生视频"
raise HTTPException(
status_code=400,
detail=f"没有可用的{label}工作流:请先到「ComfyUI 连接」确认连接可用,"
"再到工作流管理导入并启用一个"
f"{label}工作流。",
)
workflow = row_to_dict(wf)
finally:
conn.close()
inputs = parse_dynamic_inputs(json.loads(workflow["workflow_json"] or "{}"))
duration = payload.duration_sec if payload.kind == "video" else None
input_values = smart_fill_inputs(
inputs,
prompt=payload.prompt,
negative_prompt=payload.negative_prompt or None,
duration=duration,
)
job = queue_manager.enqueue(
project_id=project_id,
kind=payload.kind,
workflow_id=int(workflow["id"]),
payload={
"input_values": input_values,
"prompt": payload.prompt,
"source": WORKBENCH_SOURCE,
"stage_key": payload.stage_key,
"target_id": payload.target_id,
},
)
await event_bus.publish(
"job.created",
{
"job_id": job["id"],
"kind": payload.kind,
"source": WORKBENCH_SOURCE,
"project_id": project_id,
},
)
return {"ok": True, "job": _job_public(job)}
@@ -5,41 +5,173 @@ from typing import Any
from fastapi import APIRouter, HTTPException
from calliope.comfyui.parser import parse_dynamic_inputs, parse_dynamic_outputs
from calliope.comfyui.endpoints import client_for, endpoint_snapshot
from calliope.comfyui.lint import blocking_problem, workflow_warnings
from calliope.comfyui.metadata import suggested_metadata
from calliope.comfyui.output_map import (
auto_output_map,
load_output_map,
mapping_problem,
needs_user_output_map,
validate_output_map,
)
from calliope.comfyui.parser import (
classify_usage,
effective_input_schema,
parse_dynamic_inputs,
parse_dynamic_outputs,
)
from calliope.comfyui.preflight import check_workflow_against
from calliope.comfyui.profiles import detect_prompt_profile
from calliope.comfyui.roles import usage_to_kind
from calliope.comfyui.ui_convert import convert_ui_workflow
from calliope.comfyui.validate import validate_input_values
from calliope.config import settings
from calliope.db import get_db, row_to_dict
from calliope.models.schemas import WorkflowAnalyze, WorkflowCreate, WorkflowUpdate
from calliope.events.bus import event_bus
from calliope.models.schemas import (
WorkflowAnalyze,
WorkflowCheckRequest,
WorkflowConvertRequest,
WorkflowCreate,
WorkflowImportBatchRequest,
WorkflowImportScanRequest,
WorkflowTestRequest,
WorkflowUIFileItem,
WorkflowUpdate,
)
from calliope.queue.manager import queue_manager
from calliope.queue.workflow_test import (
TEST_SOURCE,
find_active_test_job,
list_test_jobs,
purge_workflow_tests,
)
router = APIRouter()
# HEL-681: metadata columns the lightweight list view exposes (everything
# except the heavy workflow_json / input_schema / output_map payloads).
_META_COLUMNS = (
"id, name, kind, usage, is_enabled, category, model_family, media_io, tags,"
" favorite, last_used_at, description, source, source_path, prompt_profile,"
" created_at, endpoint_id, usage_source, output_map_source"
)
def _serialize_workflow(row: Any) -> dict[str, Any]:
data = row_to_dict(row)
data["workflow_json"] = json.loads(data["workflow_json"])
data["input_schema"] = json.loads(data["input_schema"]) if data.get("input_schema") else []
data["output_schema"] = json.loads(data["output_schema"]) if data.get("output_schema") else []
stored = json.loads(data["input_schema"]) if data.get("input_schema") else []
data["input_schema"] = effective_input_schema(stored, data["workflow_json"])
# Outputs are pure graph derivation (no manual overrides) — always fresh,
# so parser upgrades reach old rows without a re-import.
data["output_schema"] = parse_dynamic_outputs(data["workflow_json"])
data["is_enabled"] = bool(data.get("is_enabled"))
data["warnings"] = workflow_warnings(data["workflow_json"])
raw_map = data.get("output_map")
data["output_map"] = load_output_map(raw_map)
# Pending flag: unknown output candidates the user has not mapped yet.
data["needs_mapping"] = (
mapping_problem(data["workflow_json"], raw_map, data.get("output_map_source")) is not None
)
# HEL-681: structured library metadata (raw JSON columns → values).
data["media_io"] = _load_json_col(data.get("media_io"), {"in": [], "out": []})
data["tags"] = _load_json_col(data.get("tags"), [])
data["favorite"] = bool(data.get("favorite"))
return data
def _load_json_col(raw: Any, default: Any) -> Any:
try:
value = json.loads(raw) if raw else None
except (json.JSONDecodeError, TypeError):
value = None
return value if value is not None else default
async def _connection_check(
endpoint_id: str | None, workflow_json: dict[str, Any], refresh: bool = False
) -> dict[str, Any]:
"""L2 preflight (HEL-643): workflow vs the target ComfyUI's /object_info.
An unreachable connection degrades to ``reachable: False`` with an honest
「无法核查」 message — it never blocks and never pretends to pass.
"""
endpoint = settings.resolve_comfyui_endpoint(endpoint_id)
if endpoint is None:
return {
"ok": False,
"reachable": False,
"endpoint_id": endpoint_id,
"endpoint_name": None,
"findings": [],
"message": "没有可用的 ComfyUI 连接,无法核查节点与模型",
}
base = {
"endpoint_id": endpoint.get("id"),
"endpoint_name": endpoint.get("name"),
"base_url": endpoint.get("base_url"),
}
client = client_for(endpoint)
try:
object_info = await client.object_info(force_refresh=refresh)
except Exception as exc:
return {
**base,
"ok": False,
"reachable": False,
"findings": [],
"message": f"无法核查(连不上该 ComfyUI):{type(exc).__name__}",
}
finally:
await client.close()
findings = check_workflow_against(object_info, workflow_json)
ok = not any(f.get("level") == "error" for f in findings)
return {**base, "ok": ok, "reachable": True, "findings": findings}
@router.post("/analyze")
async def analyze_workflow(payload: WorkflowAnalyze) -> dict[str, Any]:
inputs = parse_dynamic_inputs(payload.workflow_json)
outputs = parse_dynamic_outputs(payload.workflow_json)
return {
judgment = classify_usage(payload.workflow_json)
result: dict[str, Any] = {
"inputs": inputs,
"outputs": outputs,
"suggested_profile": detect_prompt_profile(payload.workflow_json),
"warnings": workflow_warnings(payload.workflow_json),
"usage": judgment["usage"],
"usage_ambiguous": judgment["ambiguous"],
"needs_mapping": judgment["needs_mapping"],
"usage_problem": judgment["problem"],
"usage_evidence": judgment["evidence"],
"usable_usage": judgment["usable_usage"],
}
# L2 (optional): with a target connection the analyze response also
# reports which nodes/models that ComfyUI actually has (HEL-643).
if payload.endpoint_id:
result["connection_check"] = await _connection_check(
payload.endpoint_id, payload.workflow_json, payload.refresh
)
return result
def _stored_schema(payload_schema: list[dict[str, Any]] | None, workflow: dict[str, Any]) -> str:
"""Schema to persist: the confirm page's corrected copy, else the auto one."""
if payload_schema and all(isinstance(e, dict) for e in payload_schema):
return json.dumps(payload_schema)
return json.dumps(parse_dynamic_inputs(workflow))
@router.post("/{workflow_id}/reanalyze")
async def reanalyze_workflow(workflow_id: int) -> dict[str, Any]:
"""Recompute input/output schemas from the stored workflow JSON.
"""Recompute usage and input/output schemas from the stored workflow JSON.
Workflows imported before a parser upgrade keep stale (often empty)
schemas — this refreshes them without re-importing.
schemas — this refreshes them without re-importing. A usage or output
mapping the user set by hand is never overwritten; only ``auto`` (and
pending NULL) rows get the new judgment.
"""
conn = get_db(settings.db_path)
try:
@@ -48,11 +180,40 @@ async def reanalyze_workflow(workflow_id: int) -> dict[str, Any]:
raise HTTPException(status_code=404, detail="Workflow not found")
data = row_to_dict(row)
workflow = json.loads(data["workflow_json"])
inputs = parse_dynamic_inputs(workflow)
judgment = classify_usage(workflow)
usage = data["usage"]
usage_source = data["usage_source"]
if data["usage_source"] != "user" and judgment["usage"]:
usage = judgment["usage"]
usage_source = "auto"
stored = json.loads(data["input_schema"]) if data["input_schema"] else []
schema = effective_input_schema(stored, workflow)
outputs = parse_dynamic_outputs(workflow)
if data["output_map_source"] == "user":
output_map, output_map_source = data["output_map"], "user"
elif needs_user_output_map(workflow):
# Unknown output candidates: no auto map, and the row must not
# generate until the user designates the output nodes.
output_map, output_map_source = None, None
else:
output_map, output_map_source = json.dumps(auto_output_map(workflow)), "auto"
is_enabled = data["is_enabled"]
if output_map_source is None or usage is None:
is_enabled = 0
conn.execute(
"UPDATE workflows SET input_schema = ?, output_schema = ? WHERE id = ?",
(json.dumps(inputs), json.dumps(outputs), workflow_id),
"UPDATE workflows SET input_schema = ?, output_schema = ?,"
" usage = ?, usage_source = ?, output_map = ?, output_map_source = ?,"
" is_enabled = ? WHERE id = ?",
(
json.dumps(schema),
json.dumps(outputs),
usage,
usage_source,
output_map,
output_map_source,
is_enabled,
workflow_id,
),
)
conn.commit()
fresh = conn.execute(
@@ -63,16 +224,86 @@ async def reanalyze_workflow(workflow_id: int) -> dict[str, Any]:
conn.close()
def _last_test_summary(job: dict[str, Any]) -> dict[str, Any]:
"""Slim "上次测试" record for a workflow row (queried live, never stored)."""
try:
outputs = json.loads(job.get("output_paths_json") or "[]")
except (json.JSONDecodeError, TypeError):
outputs = []
return {
"id": job["id"],
"status": job.get("status"),
"phase": job.get("phase"),
"error": job.get("error"),
"created_at": job.get("created_at"),
"completed_at": job.get("completed_at"),
"output_count": len(outputs) if isinstance(outputs, list) else 0,
}
@router.get("")
async def list_workflows() -> list[dict[str, Any]]:
async def list_workflows(view: str | None = None) -> list[dict[str, Any]]:
conn = get_db(settings.db_path)
try:
if view == "meta":
# HEL-681: metadata-only list for 150+ scale browsing — no
# workflow_json, no per-row re-parse. `needs_mapping` here is the
# cheap pending check (usage/output_map not yet settled); rows
# that never generated keep showing as pending exactly like the
# full view reports them.
rows = conn.execute(
f"SELECT {_META_COLUMNS} FROM workflows ORDER BY favorite DESC,"
" last_used_at DESC, id DESC"
).fetchall()
out = []
for row in rows:
data = row_to_dict(row)
data["is_enabled"] = bool(data.get("is_enabled"))
data["favorite"] = bool(data.get("favorite"))
data["media_io"] = _load_json_col(data.get("media_io"), {"in": [], "out": []})
data["tags"] = _load_json_col(data.get("tags"), [])
data["needs_mapping"] = data.get("usage") is None or data.get(
"output_map_source"
) is None
out.append(data)
return out
rows = conn.execute("SELECT * FROM workflows ORDER BY id DESC").fetchall()
return [_serialize_workflow(r) for r in rows]
out = [_serialize_workflow(r) for r in rows]
# 上次测试 state is read live from the newest test job per workflow —
# no cached column that could go stale (HEL-643).
latest: dict[int, dict[str, Any]] = {}
for jr in conn.execute(
"SELECT * FROM jobs WHERE origin = 'workflow_test' AND id IN"
" (SELECT MAX(id) FROM jobs WHERE origin = 'workflow_test' GROUP BY workflow_id)"
).fetchall():
latest[int(jr["workflow_id"])] = _last_test_summary(dict(jr))
for data in out:
data["last_test"] = latest.get(int(data["id"]))
return out
finally:
conn.close()
@router.post("/{workflow_id}/check")
async def check_workflow(workflow_id: int, payload: WorkflowCheckRequest) -> dict[str, Any]:
"""L2 re-check of a saved workflow against its (or a chosen) connection.
The user installs missing nodes/models in ComfyUI, comes back here and
re-checks — no re-import needed.
"""
conn = get_db(settings.db_path)
try:
row = conn.execute(
"SELECT workflow_json, endpoint_id FROM workflows WHERE id = ?", (workflow_id,)
).fetchone()
finally:
conn.close()
if not row:
raise HTTPException(status_code=404, detail="Workflow not found")
endpoint_id = payload.endpoint_id or row["endpoint_id"]
return await _connection_check(endpoint_id, json.loads(row["workflow_json"]), payload.refresh)
@router.post("/dedupe")
async def dedupe_workflows() -> dict[str, Any]:
"""Delete byte-identical duplicate workflows, keeping the lowest id.
@@ -110,38 +341,77 @@ async def dedupe_workflows() -> dict[str, Any]:
@router.post("")
async def create_workflow(payload: WorkflowCreate) -> dict[str, Any]:
inputs = parse_dynamic_inputs(payload.workflow_json)
outputs = parse_dynamic_outputs(payload.workflow_json)
judgment = classify_usage(payload.workflow_json)
usage = payload.usage or judgment["usage"]
usage_source = "user" if payload.usage else "auto"
kind = payload.kind or usage_to_kind(usage)
if kind is None:
# Indeterminate and nothing chosen: classify_output hint keeps old
# callers working while the workflow stays pending confirmation.
out_kinds = {
o.get("kind")
for o in parse_dynamic_outputs(payload.workflow_json)
if o.get("kind") in ("image", "video")
}
kind = "video" if out_kinds == {"video"} else "image"
profile = payload.prompt_profile or detect_prompt_profile(payload.workflow_json)
# HEL-638: an explicit user output map wins; otherwise the trusted auto
# map. Either way a graph with unknown output candidates is unusable
# until the user designates the output nodes.
if payload.output_map:
problems = validate_output_map(payload.workflow_json, payload.output_map)
if problems:
raise HTTPException(status_code=422, detail=";".join(problems))
output_map = json.dumps(payload.output_map)
output_map_source = payload.output_map_source or "user"
else:
auto_map = auto_output_map(payload.workflow_json)
output_map = json.dumps(auto_map) if auto_map else None
output_map_source = "auto" if auto_map else None
mapping_ok = not needs_user_output_map(payload.workflow_json) or bool(payload.output_map)
conn = get_db(settings.db_path)
try:
# Byte-identical re-imports are the main way the library fills up with
# same-named clones (the "@" typeahead then shows 20 identical rows).
existing = conn.execute(
"SELECT id, name FROM workflows WHERE name = ? AND kind = ? AND workflow_json = ?",
(payload.name, payload.kind, json.dumps(payload.workflow_json)),
(payload.name, kind, json.dumps(payload.workflow_json)),
).fetchone()
if existing:
raise HTTPException(
status_code=409,
detail=f"An identical workflow already exists (id {existing['id']}, “{existing['name']}”).",
detail=(
f"An identical workflow already exists (id {existing['id']},"
f" “{existing['name']}”)."
),
)
# Unconfirmed workflows (ambiguous usage / unknown outputs without a
# user mapping) are stored but disabled: they must not reach the
# generate pages until the user confirms usage AND, for unknown
# output nodes, designates the outputs on the connections page.
is_enabled = 1 if (usage and mapping_ok) else 0
cur = conn.execute(
"""
INSERT INTO workflows (name, kind, workflow_json, input_schema,
output_schema, description, prompt_profile,
endpoint_id, is_enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
endpoint_id, is_enabled, usage, usage_source,
output_map, output_map_source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
payload.name,
payload.kind,
kind,
json.dumps(payload.workflow_json),
json.dumps(inputs),
json.dumps(outputs),
_stored_schema(payload.input_schema, payload.workflow_json),
json.dumps(parse_dynamic_outputs(payload.workflow_json)),
payload.description,
profile,
payload.endpoint_id,
is_enabled,
usage,
usage_source,
output_map,
output_map_source,
),
)
conn.commit()
@@ -173,9 +443,60 @@ async def update_workflow(workflow_id: int, payload: WorkflowUpdate) -> dict[str
data = payload.model_dump(exclude_unset=True)
if "is_enabled" in data and data["is_enabled"] is not None:
data["is_enabled"] = 1 if data["is_enabled"] else 0
# endpoint_id: explicit null clears the binding; other Nones are skips.
explicit_null = {"endpoint_id"} & {k for k, v in data.items() if v is None}
# HEL-681: library metadata patch fields (favorite/tags/category/
# model_family) — booleans to ints, lists to JSON text.
if "favorite" in data and data["favorite"] is not None:
data["favorite"] = 1 if data["favorite"] else 0
if data.get("tags") is not None:
data["tags"] = json.dumps(data["tags"])
workflow_json = json.loads(existing["workflow_json"])
# HEL-638: output_map payload. A non-empty list validates against the
# stored JSON and becomes the user decision; an explicit empty list
# resets the row to pending (disabled).
if "output_map" in data:
if data["output_map"]:
problems = validate_output_map(workflow_json, data["output_map"])
if problems:
raise HTTPException(status_code=422, detail=";".join(problems))
data["output_map"] = json.dumps(data["output_map"])
data.setdefault("output_map_source", "user")
else:
data["output_map"] = None
data["output_map_source"] = None
data["is_enabled"] = 0
if data.get("usage"):
# Confirming / correcting the usage is an explicit user decision:
# kind (output media) follows the usage. Whether it re-enables is
# decided by the gate below — with unknown output nodes the user
# must also designate the output nodes (HEL-638).
data.setdefault("usage_source", "user")
data["kind"] = usage_to_kind(data["usage"]) or existing["kind"]
if "input_schema" in data and data["input_schema"] is not None:
if not all(isinstance(e, dict) for e in data["input_schema"]):
raise HTTPException(status_code=422, detail="input_schema 必须是参数对象数组")
data["input_schema"] = json.dumps(data["input_schema"])
# endpoint_id: explicit null clears the binding; other Nones are skips
# (an output_map reset keeps its explicit NULLs).
explicit_null = {"endpoint_id", "output_map", "output_map_source"} & {
k for k, v in data.items() if v is None
}
data = {k: v for k, v in data.items() if v is not None or k in explicit_null}
# Enable gate: usage must be settled and, when the graph carries
# unknown custom output nodes, a user output mapping must exist.
usage_ok = bool(data.get("usage") or existing["usage"])
map_raw = data.get("output_map", existing["output_map"])
map_source = data.get("output_map_source", existing["output_map_source"])
map_problem = mapping_problem(workflow_json, map_raw, map_source)
if data.get("is_enabled"):
if not usage_ok:
raise HTTPException(status_code=400, detail="请先选择工作流用途,再启用该工作流")
if map_problem:
raise HTTPException(status_code=400, detail=map_problem)
elif (data.get("usage") or data.get("output_map")) and "is_enabled" not in data:
# A usage-confirm or output-map-confirm path: enabled only when
# both decisions are settled.
data["is_enabled"] = 1 if (usage_ok and not map_problem) else 0
if data:
fields = ", ".join(f"{k} = :{k}" for k in data)
data["id"] = workflow_id
@@ -187,14 +508,426 @@ async def update_workflow(workflow_id: int, payload: WorkflowUpdate) -> dict[str
conn.close()
def _job_public(job: dict[str, Any]) -> dict[str, Any]:
out = dict(job)
out["payload"] = json.loads(job.get("payload_json") or "{}")
out["output_paths"] = json.loads(job.get("output_paths_json") or "[]")
out.pop("payload_json", None)
out.pop("output_paths_json", None)
return out
@router.post("/{workflow_id}/test")
async def test_workflow(workflow_id: int, payload: WorkflowTestRequest) -> dict[str, Any]:
"""Launch (or re-attach to) one REAL test run of a workflow (HEL-643 L3).
The job goes through the ordinary queue → ComfyUI → outputs pipeline;
only its artifacts and bookkeeping are isolated (origin='workflow_test').
A dry-run setting is refused outright — placeholder output must never
count as a test pass. Repeat clicks reuse the in-flight job.
"""
from calliope import config
if config.settings.dry_run:
raise HTTPException(
status_code=400,
detail=(
"测试工作流必须真实运行:当前设置开启了 Dry-run(占位模式),"
"请到「设置」关闭 Dry-run 后再测试"
),
)
conn = get_db(settings.db_path)
try:
wf = conn.execute(
"SELECT id, kind, workflow_json, input_schema, output_map, output_map_source,"
" endpoint_id FROM workflows WHERE id = ?",
(workflow_id,),
).fetchone()
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
workflow_json = json.loads(wf["workflow_json"])
broken = blocking_problem(workflow_json)
if broken:
raise HTTPException(status_code=400, detail=broken)
map_problem = mapping_problem(workflow_json, wf["output_map"], wf["output_map_source"])
if map_problem:
raise HTTPException(status_code=400, detail=map_problem)
schema = effective_input_schema(
json.loads(wf["input_schema"]) if wf["input_schema"] else [], workflow_json
)
input_values, problems = validate_input_values(schema, payload.input_values)
if problems:
raise HTTPException(status_code=400, detail="参数有误:" + ";".join(problems))
endpoint_id = payload.endpoint_id or wf["endpoint_id"] or None
snapshot = endpoint_snapshot(endpoint_id)
active = find_active_test_job(workflow_id, (snapshot or {}).get("id"))
if active is not None:
return {"ok": True, "job": _job_public(active), "reused": True}
from calliope.routers.playground import ensure_playground_project
pid = ensure_playground_project(conn)
finally:
conn.close()
job = queue_manager.enqueue(
project_id=pid,
kind=wf["kind"],
workflow_id=workflow_id,
scene_id=None,
payload={
"input_values": input_values,
"source": TEST_SOURCE,
**({"endpoint_id": payload.endpoint_id} if payload.endpoint_id else {}),
},
origin="workflow_test",
)
await event_bus.publish(
"job.created",
{"job_id": job["id"], "kind": job["kind"], "source": TEST_SOURCE,
"project_id": job["project_id"], "workflow_test": True},
)
return {"ok": True, "job": _job_public(job), "reused": False}
@router.get("/{workflow_id}/tests")
async def list_workflow_tests(workflow_id: int) -> list[dict[str, Any]]:
conn = get_db(settings.db_path)
try:
row = conn.execute("SELECT id FROM workflows WHERE id = ?", (workflow_id,)).fetchone()
finally:
conn.close()
if not row:
raise HTTPException(status_code=404, detail="Workflow not found")
return list_test_jobs(workflow_id)
@router.post("/{workflow_id}/clear-tests")
async def clear_workflow_tests(workflow_id: int) -> dict[str, Any]:
"""Delete every test record and artifact for this workflow."""
conn = get_db(settings.db_path)
try:
row = conn.execute("SELECT id FROM workflows WHERE id = ?", (workflow_id,)).fetchone()
finally:
conn.close()
if not row:
raise HTTPException(status_code=404, detail="Workflow not found")
try:
removed = purge_workflow_tests(workflow_id)
except RuntimeError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
await event_bus.publish("job.deleted", {"workflow_id": workflow_id, "source": TEST_SOURCE})
return {"ok": True, "removed": removed}
@router.delete("/{workflow_id}")
async def delete_workflow(workflow_id: int) -> dict[str, bool]:
conn = get_db(settings.db_path)
try:
cur = conn.execute("DELETE FROM workflows WHERE id = ?", (workflow_id,))
conn.commit()
if cur.rowcount == 0:
# HEL-619: refuse to delete while queued/running jobs still use the
# workflow — deleting sets their workflow_id NULL and the job dies
# with "工作流已被删除" only after the user waits for it.
row = conn.execute(
"SELECT id FROM workflows WHERE id = ?", (workflow_id,)
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Workflow not found")
pending = conn.execute(
"SELECT COUNT(*) AS n FROM jobs"
" WHERE workflow_id = ? AND status IN ('pending', 'running')",
(workflow_id,),
).fetchone()["n"]
if pending:
raise HTTPException(
status_code=409,
detail=(
f"该工作流还有 {pending} 个排队或执行中的任务,"
"请先等任务完成或取消任务,再删除该工作流"
),
)
# Test records/artifacts belong to the workflow — cascade (HEL-643).
# The 409 above guarantees none are in flight here.
purge_workflow_tests(workflow_id, allow_active=True)
conn.execute("DELETE FROM workflows WHERE id = ?", (workflow_id,))
conn.commit()
return {"ok": True}
finally:
conn.close()
# ---------------------------------------------------------------------------
# HEL-681 (S2): UI→API conversion dry-run + batch import + repo scan.
# The convert endpoint never writes; import-batch converts again server-side
# and skips anything uncertain (needs_confirmation / lint-blocked / conflict)
# — repeated runs import nothing new, so the batch is idempotent by design.
# ---------------------------------------------------------------------------
def _convert_one(item: WorkflowUIFileItem) -> tuple[dict[str, Any], dict[str, Any] | None]:
"""(report, converted workflow) for one UI file — shared by every
convert/import path so the analysis can never drift between them."""
result = convert_ui_workflow(item.workflow_json)
stem = (
item.source_path.replace("\\", "/").rsplit("/", 1)[-1].removesuffix(".json")
if item.source_path
else None
)
name = item.name or stem or "未命名工作流"
report: dict[str, Any] = {
"name": name,
"source_path": item.source_path,
"status": result.status,
"needs_confirmation": result.needs_confirmation,
"issues": [
{"node_id": i.node_id, "code": i.code, "level": i.level, "message": i.message}
for i in result.issues
],
}
if result.workflow is None:
report["importable"] = False
report["skip_reason"] = "转换失败:" + (
result.issues[0].message if result.issues else "未知错误"
)
return report, None
suggested = suggested_metadata(result.workflow, item.source_path, item.workflow_json)
report["suggested"] = suggested
# The import gate re-runs the product's own checks — a converted graph
# that the lint would block (missing model link etc.) is reported, not
# silently imported.
broken = blocking_problem(result.workflow)
if broken:
report["importable"] = False
report["skip_reason"] = broken
report["status"] = "warn"
return report, result.workflow
if result.needs_confirmation:
report["importable"] = False
report["skip_reason"] = "存在无法可靠映射的控件,需人工确认后重导"
return report, result.workflow
report["importable"] = True
return report, result.workflow
@router.post("/convert")
async def convert_workflows(payload: WorkflowConvertRequest) -> dict[str, Any]:
"""Dry-run UI→API conversion for a batch of files: per-file ok/warn/fail,
issues, suggested metadata and importability. Nothing is stored."""
files = []
object_info: dict[str, Any] | None = None
client = None
if payload.endpoint_id:
endpoint = settings.resolve_comfyui_endpoint(payload.endpoint_id)
if endpoint is not None:
client = client_for(endpoint)
try:
object_info = await client.object_info(force_refresh=payload.refresh)
except Exception:
object_info = None
try:
for item in payload.files:
report, converted = _convert_one(item)
if object_info is not None and report.get("importable") and converted:
findings = check_workflow_against(object_info, converted)
errors = [f for f in findings if f.get("level") == "error"]
report["preflight"] = {
"endpoint_id": payload.endpoint_id,
"ok": not errors,
"findings": findings,
}
if errors:
report["importable"] = False
report["skip_reason"] = errors[0].get("message") or "preflight 未通过"
if payload.include_workflows:
report["workflow_json"] = converted
files.append(report)
finally:
if client is not None:
await client.close()
counts = {key: sum(1 for f in files if f["status"] == key) for key in ("ok", "warn", "fail")}
return {
"total": len(files),
**counts,
"needs_confirmation": sum(1 for f in files if f["needs_confirmation"]),
"importable": sum(1 for f in files if f.get("importable")),
"files": files,
}
def _insert_converted(
conn: Any, item: WorkflowUIFileItem, report: dict[str, Any], converted: dict[str, Any]
) -> dict[str, Any]:
"""Insert one converted workflow row (import-batch's write path).
Mirrors create_workflow's enable gate: usage must be settled (auto or the
wizard's confirmation) and unknown outputs need a mapping; anything less
lands disabled exactly like a manual import would.
"""
suggested = report["suggested"]
judgment = classify_usage(converted)
usage = item.usage or judgment["usage"]
usage_source = "user" if item.usage else "auto"
kind = usage_to_kind(usage) or suggested["kind"]
profile = detect_prompt_profile(converted)
if item.output_map:
problems = validate_output_map(converted, item.output_map)
if problems:
raise HTTPException(status_code=422, detail=";".join(problems))
output_map = json.dumps(item.output_map)
output_map_source = "user"
else:
auto_map = auto_output_map(converted)
output_map = json.dumps(auto_map) if auto_map else None
output_map_source = "auto" if auto_map else None
mapping_ok = not needs_user_output_map(converted) or bool(item.output_map)
workflow_text = json.dumps(converted)
existing = conn.execute(
"SELECT id FROM workflows WHERE name = ? AND kind = ? AND workflow_json = ?",
(report["name"], kind, workflow_text),
).fetchone()
if existing:
return {
"result": "skipped",
"skip_reason": "conflict",
"existing_id": existing["id"],
}
is_enabled = 1 if (usage and mapping_ok) else 0
cur = conn.execute(
"""
INSERT INTO workflows (name, kind, workflow_json, input_schema, output_schema,
description, prompt_profile, endpoint_id, is_enabled,
usage, usage_source, output_map, output_map_source,
category, model_family, media_io, tags, favorite,
source, source_path)
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 'ui_convert', ?)
""",
(
report["name"],
kind,
workflow_text,
json.dumps(
item.input_schema if item.input_schema else parse_dynamic_inputs(converted)
),
json.dumps(parse_dynamic_outputs(converted)),
item.description if item.description is not None else suggested["description"],
profile,
is_enabled,
usage,
usage_source,
output_map,
output_map_source,
suggested["category"],
suggested["model_family"],
json.dumps({"in": suggested["media_in"], "out": suggested["media_out"]}),
json.dumps(item.tags or []),
item.source_path,
),
)
return {"result": "imported", "id": cur.lastrowid}
async def _run_import(items: list[WorkflowUIFileItem]) -> dict[str, Any]:
reports: list[dict[str, Any]] = []
conn = get_db(settings.db_path)
try:
for item in items:
report, converted = _convert_one(item)
if not report.get("importable"):
report["result"] = "skipped"
reports.append(report)
continue
try:
outcome = _insert_converted(conn, item, report, converted)
except HTTPException as exc:
report["result"] = "failed"
report["skip_reason"] = str(exc.detail)
reports.append(report)
continue
conn.commit()
report.update(outcome)
reports.append(report)
finally:
conn.close()
imported = [r for r in reports if r.get("result") == "imported"]
skipped = [
{"name": r["name"], "source_path": r["source_path"], "reason": r.get("skip_reason")}
for r in reports
if r.get("result") != "imported"
]
return {
"total": len(reports),
"imported": len(imported),
"skipped": len(skipped),
"imported_ids": [r["id"] for r in imported],
"skipped_files": skipped,
"files": reports,
}
@router.post("/import-batch")
async def import_workflows_batch(payload: WorkflowImportBatchRequest) -> dict[str, Any]:
"""Batch-import UI-format workflows: convert server-side, insert what is
cleanly convertible, skip conflicts and anything uncertain. Re-running
with the same files creates zero new rows."""
return await _run_import(payload.files)
@router.post("/import-scan")
async def import_workflows_scan(payload: WorkflowImportScanRequest) -> dict[str, Any]:
"""Scan a server-side directory for UI-format workflow JSONs and run the
convert (dry_run, default) or import pipeline over every file found."""
from pathlib import Path
root = Path(payload.root)
if not root.is_absolute():
candidates = [
Path.cwd() / root,
Path(__file__).resolve().parents[4] / root,
]
root = next((c for c in candidates if c.is_dir()), root)
if not root.is_dir():
raise HTTPException(status_code=404, detail=f"目录不存在:{payload.root}")
items: list[WorkflowUIFileItem] = []
for path in sorted(root.rglob("*.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
continue
if not (isinstance(data, dict) and isinstance(data.get("nodes"), list)):
continue # API-format or non-workflow files: not this pipeline's job
items.append(
WorkflowUIFileItem(
source_path=str(path.relative_to(root)), workflow_json=data
)
)
if payload.dry_run:
files = [_convert_one(item)[0] for item in items]
counts = {
key: sum(1 for f in files if f["status"] == key) for key in ("ok", "warn", "fail")
}
return {
"total": len(files),
**counts,
"needs_confirmation": sum(1 for f in files if f["needs_confirmation"]),
"importable": sum(1 for f in files if f.get("importable")),
"files": files,
}
return await _run_import(items)
@router.post("/{workflow_id}/touch")
async def touch_workflow(workflow_id: int) -> dict[str, bool]:
"""Record a use (最近使用) — called by the generate pipeline on enqueue."""
conn = get_db(settings.db_path)
try:
row = conn.execute("SELECT id FROM workflows WHERE id = ?", (workflow_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Workflow not found")
conn.execute(
"UPDATE workflows SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?",
(workflow_id,),
)
conn.commit()
return {"ok": True}
finally:
conn.close()
+4
View File
@@ -10,6 +10,10 @@ from fastapi.testclient import TestClient
from calliope.db import migrate_db
from calliope.main import create_app
# Shared fake-ComfyUI HTTP server (defined in test_studio_generation,
# reused by the generation / status / unknown-output-mapping suites).
from tests.test_studio_generation import fake_comfy # noqa: F401,E402
@pytest.fixture
def client(monkeypatch):
@@ -0,0 +1,322 @@
{
"1": {
"inputs": {
"value": "PASTE YOUR PROMPT HERE. For image-to-video describe the motion from the first frame; the frame fixes the look. Use the MiniMax-H3 prompt contract (integrated_multimodal_description / overall_soundscape / non_diegetic_music)."
},
"class_type": "PrimitiveStringMultiline",
"_meta": {
"title": "PROMPT (feeds every stage)"
}
},
"92": {
"inputs": {
"filename_prefix": "video/04_i2v_fl2v_4step_sla",
"format": "auto",
"format.codec": "auto",
"codec": "auto",
"video": [
"130",
0
]
},
"class_type": "SaveVideo",
"_meta": {
"title": "REFERENCE 2026-08-29: fused ref-delta bake + SLA 0.9/64, res_multistep/simple/4, shift 12/3, 1152x640, 243 f, seed 42 -> subway_bake_sla_00001_.mp4"
}
},
"115": {
"inputs": {
"aspect_ratio": "16:9 (Widescreen)",
"megapixels": 0.7,
"multiple": 32
},
"class_type": "ResolutionSelector",
"_meta": {
"title": "ResolutionSelector"
}
},
"116": {
"inputs": {
"expression": "a"
},
"class_type": "ComfyMathExpression",
"_meta": {
"title": "ComfyMathExpression"
}
},
"119": {
"inputs": {
"vae_name": "minimax_h3_video_vae_int8_convrot.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "VAELoader"
}
},
"120": {
"inputs": {
"vae_name": "minimax_h3_audio_vae_fp32.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "VAELoader"
}
},
"121": {
"inputs": {
"samples": [
"125",
0
],
"vae": [
"120",
0
]
},
"class_type": "VAEDecodeAudio",
"_meta": {
"title": "VAEDecodeAudio"
}
},
"122": {
"inputs": {
"samples": [
"125",
0
],
"vae": [
"119",
0
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAEDecode"
}
},
"124": {
"inputs": {
"scheduler": "simple",
"steps": 4,
"denoise": 1,
"model": [
"142",
0
]
},
"class_type": "BasicScheduler",
"_meta": {
"title": "BasicScheduler"
}
},
"125": {
"inputs": {
"noise": [
"129",
0
],
"guider": [
"126",
0
],
"sampler": [
"135",
0
],
"sigmas": [
"124",
0
],
"latent_image": [
"131",
1
]
},
"class_type": "SamplerCustomAdvanced",
"_meta": {
"title": "SamplerCustomAdvanced"
}
},
"126": {
"inputs": {
"model": [
"142",
0
],
"conditioning": [
"131",
0
]
},
"class_type": "BasicGuider",
"_meta": {
"title": "BasicGuider"
}
},
"127": {
"inputs": {
"unet_name": "minimax_h3_fused_refdelta_r1024_turbo8_mystic07_int8_convrot.safetensors",
"weight_dtype": "default"
},
"class_type": "UNETLoader",
"_meta": {
"title": "UNETLoader"
}
},
"128": {
"inputs": {
"clip_name": "qwen3vl_32b_heretic_minimax_h3_nvfp4.safetensors",
"type": "minimax",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": {
"title": "CLIPLoader"
}
},
"129": {
"inputs": {
"noise_seed": 42
},
"class_type": "RandomNoise",
"_meta": {
"title": "RandomNoise"
}
},
"130": {
"inputs": {
"fps": 24,
"bit_depth": 8,
"color_space": "sRGB",
"codec": "none",
"images": [
"122",
0
],
"audio": [
"121",
0
]
},
"class_type": "CreateVideo",
"_meta": {
"title": "CreateVideo"
}
},
"131": {
"inputs": {
"prompt": [
"1",
0
],
"width": [
"115",
0
],
"height": [
"115",
1
],
"length": [
"132",
1
],
"clip": [
"128",
0
],
"vae": [
"119",
0
],
"first_frame": [
"901",
0
]
},
"class_type": "MiniMaxH3ImageToVideo",
"_meta": {
"title": "MiniMaxH3ImageToVideo"
}
},
"132": {
"inputs": {
"expression": "max(5, round(a * 24)) + (5 - (max(5, round(a * 24)) % 17)) % 17",
"values.a": [
"133",
0
]
},
"class_type": "ComfyMathExpression",
"_meta": {
"title": "ComfyMathExpression"
}
},
"133": {
"inputs": {
"value": 10
},
"class_type": "PrimitiveFloat",
"_meta": {
"title": "PrimitiveFloat"
}
},
"135": {
"inputs": {
"sampler_name": "res_multistep"
},
"class_type": "KSamplerSelect",
"_meta": {
"title": "KSamplerSelect"
}
},
"142": {
"inputs": {
"shift_video": 12,
"shift_audio": 3,
"model": [
"900",
0
]
},
"class_type": "MiniMaxH3SigmaShift",
"_meta": {
"title": "MiniMaxH3SigmaShift"
}
},
"900": {
"inputs": {
"sparsity_ratio": 0.9,
"block_size": "64",
"min_seq_len": 8192,
"dense_last_steps": 0,
"protect_audio": true,
"enabled": true,
"dense_steps": "1",
"dense_backend": "comfy_kitchen",
"disable_fp16_accum": true,
"stabilize_motion": false,
"reference_protection": "Off",
"tail_correction": false,
"use_int8_qk": true,
"engine": "comfy_kitchen",
"model": [
"127",
0
]
},
"class_type": "H3SLAAttention",
"_meta": {
"title": "H3 SLA Attention 0.9/64 (their graph)"
}
},
"901": {
"inputs": {
"image": "first_frame.png"
},
"class_type": "LoadImage",
"_meta": {
"title": "FIRST FRAME (add a second LoadImage into last_frame for FL2V)"
}
}
}
@@ -0,0 +1,327 @@
{
"1": {
"inputs": {
"value": "PASTE YOUR PROMPT HERE, in the reference contract:\nsubject_definitions:\n<Subject 1> is the person in <Picture 1>; their face, hair and clothing follow <Picture 1>.\n\nsummary:\n[reference generation] one continuous shot of <Subject 1> doing X, appearance taken from <Picture 1>.\n\nretention_analysis:\n<Subject 1> is fully preserved from <Picture 1>.\n\ndetailed_description:\n[Shot 1] ... camera, action, timing ...\n\noverall_soundscape: ...\n\nnon_diegetic_music: N/A"
},
"class_type": "PrimitiveStringMultiline",
"_meta": {
"title": "PROMPT (feeds every stage)"
}
},
"92": {
"inputs": {
"filename_prefix": "video/05_ref2va_4step_sla",
"format": "auto",
"format.codec": "auto",
"codec": "auto",
"video": [
"130",
0
]
},
"class_type": "SaveVideo",
"_meta": {
"title": "REFERENCE 2026-08-29: fused ref-delta bake + SLA 0.9/64, res_multistep/simple/4, shift 12/3, 1152x640, 243 f, seed 42 -> subway_bake_sla_00001_.mp4"
}
},
"115": {
"inputs": {
"aspect_ratio": "16:9 (Widescreen)",
"megapixels": 0.7,
"multiple": 32
},
"class_type": "ResolutionSelector",
"_meta": {
"title": "ResolutionSelector"
}
},
"116": {
"inputs": {
"expression": "a"
},
"class_type": "ComfyMathExpression",
"_meta": {
"title": "ComfyMathExpression"
}
},
"119": {
"inputs": {
"vae_name": "minimax_h3_video_vae_int8_convrot.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "VAELoader"
}
},
"120": {
"inputs": {
"vae_name": "minimax_h3_audio_vae_fp32.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "VAELoader"
}
},
"121": {
"inputs": {
"samples": [
"125",
0
],
"vae": [
"120",
0
]
},
"class_type": "VAEDecodeAudio",
"_meta": {
"title": "VAEDecodeAudio"
}
},
"122": {
"inputs": {
"samples": [
"125",
0
],
"vae": [
"119",
0
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAEDecode"
}
},
"124": {
"inputs": {
"scheduler": "simple",
"steps": 4,
"denoise": 1,
"model": [
"142",
0
]
},
"class_type": "BasicScheduler",
"_meta": {
"title": "BasicScheduler"
}
},
"125": {
"inputs": {
"noise": [
"129",
0
],
"guider": [
"126",
0
],
"sampler": [
"135",
0
],
"sigmas": [
"124",
0
],
"latent_image": [
"131",
1
]
},
"class_type": "SamplerCustomAdvanced",
"_meta": {
"title": "SamplerCustomAdvanced"
}
},
"126": {
"inputs": {
"model": [
"142",
0
],
"conditioning": [
"131",
0
]
},
"class_type": "BasicGuider",
"_meta": {
"title": "BasicGuider"
}
},
"127": {
"inputs": {
"unet_name": "minimax_h3_fused_refdelta_r1024_turbo8_mystic07_int8_convrot.safetensors",
"weight_dtype": "default"
},
"class_type": "UNETLoader",
"_meta": {
"title": "UNETLoader"
}
},
"128": {
"inputs": {
"clip_name": "qwen3vl_32b_heretic_minimax_h3_nvfp4.safetensors",
"type": "minimax",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": {
"title": "CLIPLoader"
}
},
"129": {
"inputs": {
"noise_seed": 42
},
"class_type": "RandomNoise",
"_meta": {
"title": "RandomNoise"
}
},
"130": {
"inputs": {
"fps": 24,
"bit_depth": 8,
"color_space": "sRGB",
"codec": "none",
"images": [
"122",
0
],
"audio": [
"121",
0
]
},
"class_type": "CreateVideo",
"_meta": {
"title": "CreateVideo"
}
},
"131": {
"inputs": {
"prompt": [
"1",
0
],
"width": [
"115",
0
],
"height": [
"115",
1
],
"length": [
"132",
1
],
"ref_image_size": "match",
"clip": [
"128",
0
],
"vae": [
"119",
0
],
"audio_vae": [
"120",
0
],
"ref_images.ref_image_0": [
"901",
0
]
},
"class_type": "MiniMaxH3ReferenceToVideo",
"_meta": {
"title": "MiniMaxH3ReferenceToVideo"
}
},
"132": {
"inputs": {
"expression": "max(5, round(a * 24)) + (5 - (max(5, round(a * 24)) % 17)) % 17",
"values.a": [
"133",
0
]
},
"class_type": "ComfyMathExpression",
"_meta": {
"title": "ComfyMathExpression"
}
},
"133": {
"inputs": {
"value": 10
},
"class_type": "PrimitiveFloat",
"_meta": {
"title": "PrimitiveFloat"
}
},
"135": {
"inputs": {
"sampler_name": "res_multistep"
},
"class_type": "KSamplerSelect",
"_meta": {
"title": "KSamplerSelect"
}
},
"142": {
"inputs": {
"shift_video": 12,
"shift_audio": 3,
"model": [
"900",
0
]
},
"class_type": "MiniMaxH3SigmaShift",
"_meta": {
"title": "MiniMaxH3SigmaShift"
}
},
"900": {
"inputs": {
"sparsity_ratio": 0.9,
"block_size": "64",
"min_seq_len": 8192,
"dense_last_steps": 0,
"protect_audio": true,
"enabled": true,
"dense_steps": "1",
"dense_backend": "comfy_kitchen",
"disable_fp16_accum": true,
"stabilize_motion": false,
"reference_protection": "Off",
"tail_correction": false,
"use_int8_qk": true,
"engine": "comfy_kitchen",
"model": [
"127",
0
]
},
"class_type": "H3SLAAttention",
"_meta": {
"title": "H3 SLA Attention 0.9/64 (their graph)"
}
},
"901": {
"inputs": {
"image": "reference_1.png"
},
"class_type": "LoadImage",
"_meta": {
"title": "REFERENCE IMAGE 1 (<Picture 1>); the node grows more image / video / audio slots"
}
}
}
@@ -0,0 +1,808 @@
{
"115": {
"inputs": {
"aspect_ratio": "9:16 (Portrait Widescreen)",
"megapixels": 0.6,
"multiple": 32
},
"class_type": "ResolutionSelector",
"_meta": {
"title": "Resolution Selector"
}
},
"207": {
"inputs": {
"vae_name": "minimax-h3\\minimax_h3_video_vae_fp16.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "Load VAE"
}
},
"208": {
"inputs": {
"vae_name": "minimax-h3\\minimax_h3_audio_vae_fp32.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "Load VAE"
}
},
"209": {
"inputs": {
"samples": [
"213",
0
],
"vae": [
"208",
0
]
},
"class_type": "VAEDecodeAudio",
"_meta": {
"title": "VAE Decode Audio"
}
},
"210": {
"inputs": {
"samples": [
"213",
0
],
"vae": [
"207",
0
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAE Decode"
}
},
"211": {
"inputs": {
"sampler_name": "euler"
},
"class_type": "KSamplerSelect",
"_meta": {
"title": "KSamplerSelect"
}
},
"212": {
"inputs": {
"scheduler": "beta",
"steps": 6,
"denoise": 1,
"model": [
"6007",
0
]
},
"class_type": "BasicScheduler",
"_meta": {
"title": "BasicScheduler"
}
},
"213": {
"inputs": {
"noise": [
"217",
0
],
"guider": [
"214",
0
],
"sampler": [
"211",
0
],
"sigmas": [
"212",
0
],
"latent_image": [
"219",
1
]
},
"class_type": "SamplerCustomAdvanced",
"_meta": {
"title": "SamplerCustomAdvanced"
}
},
"214": {
"inputs": {
"model": [
"6007",
0
],
"conditioning": [
"219",
0
]
},
"class_type": "BasicGuider",
"_meta": {
"title": "Basic Guider"
}
},
"215": {
"inputs": {
"unet_name": "minimax-h3\\minimax_h3_fl2va_int8_convrot.safetensors",
"weight_dtype": "default"
},
"class_type": "UNETLoader",
"_meta": {
"title": "Load Diffusion Model"
}
},
"216": {
"inputs": {
"clip_name": "minimax-h3\\qwen3vl_32b_minimax_h3_int8_convrot.comfy.safetensors",
"type": "minimax",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": {
"title": "Load CLIP"
}
},
"217": {
"inputs": {
"noise_seed": 42
},
"class_type": "RandomNoise",
"_meta": {
"title": "RandomNoise"
}
},
"219": {
"inputs": {
"prompt": [
"6005",
0
],
"width": [
"115",
0
],
"height": [
"115",
1
],
"length": [
"220",
1
],
"clip": [
"6010",
1
],
"vae": [
"207",
0
]
},
"class_type": "MiniMaxH3ImageToVideo",
"_meta": {
"title": "MiniMax H3 Image to Video"
}
},
"220": {
"inputs": {
"expression": "max(5, round(a * 24)) + (5 - (max(5, round(a * 24)) % 17)) % 17",
"values.a": [
"221",
0
]
},
"class_type": "ComfyMathExpression",
"_meta": {
"title": "Math Expression"
}
},
"221": {
"inputs": {
"value": 10
},
"class_type": "PrimitiveFloat",
"_meta": {
"title": "Seconds"
}
},
"223": {
"inputs": {
"sage_attention": "auto",
"allow_compile": false,
"model": [
"6020",
0
]
},
"class_type": "PathchSageAttentionKJ",
"_meta": {
"title": "Patch Sage Attention KJ"
}
},
"5970": {
"inputs": {
"model_name": "film_net_fp16.safetensors"
},
"class_type": "FrameInterpolationModelLoader",
"_meta": {
"title": "Load Frame Interpolation Model"
}
},
"5971": {
"inputs": {
"multiplier": 2,
"interp_model": [
"5970",
0
],
"images": [
"5976",
0
]
},
"class_type": "FrameInterpolate",
"_meta": {
"title": "Run Frame Interpolation Model"
}
},
"5974": {
"inputs": {
"expression": "a*2",
"a": [
"5986",
0
]
},
"class_type": "MathExpression|pysssss",
"_meta": {
"title": "Math Expression 🐍"
}
},
"5975": {
"inputs": {
"resize_type": "scale by multiplier",
"resize_type.scale": 1.5,
"quality": "ULTRA",
"images": [
"5971",
0
]
},
"class_type": "RTXVideoSuperResolution",
"_meta": {
"title": "RTX Video Super Resolution Pro"
}
},
"5976": {
"inputs": {
"anything": [
"5977",
0
]
},
"class_type": "easy clearCacheAll",
"_meta": {
"title": "Clear Cache All"
}
},
"5977": {
"inputs": {
"anything": [
"6063:5538",
0
]
},
"class_type": "easy cleanGpuUsed",
"_meta": {
"title": "Clean VRAM Used"
}
},
"5978": {
"inputs": {
"frame_rate": [
"5974",
1
],
"loop_count": 0,
"filename_prefix": "minimax-h3/vid-vfi",
"format": "video/h264-mp4",
"pix_fmt": "yuv420p",
"crf": 19,
"save_metadata": true,
"trim_to_audio": false,
"pingpong": false,
"save_output": true,
"images": [
"5975",
0
],
"audio": [
"6063:4848",
0
]
},
"class_type": "VHS_VideoCombine",
"_meta": {
"title": "Video Output 2"
}
},
"5986": {
"inputs": {
"value": 24
},
"class_type": "PrimitiveFloat",
"_meta": {
"title": "PrimitiveFloat"
}
},
"6005": {
"inputs": {
"value": "integrated_multimodal_description: [Shot 1] Cinematic, live-action, shot on iPhone, a vertical selfie frame from a phone held at arm's length above eye level. A young woman in her mid-twenties with sun-kissed tan skin and loose wavy chestnut brown hair, wearing a white cropped t-shirt with a gold pendant necklace, looks directly into the phone lens, her face filling the centre of frame. Warm golden hour sunlight hits the right side of her face from a low sun behind the trees, casting a soft amber glow across her skin. Behind her, a city park is blurred into soft green and gold bokeh — trees, dappled light, a hot pink sunset sky bleeding through the leaves. The camera holds static in selfie framing. The young woman's lips move as she speaks with a bright, energetic voice with a slight North American accent (S1): <d>[English] Oh my god, you guys, it is actually perfect out here right now.</d> Her mouth closes after the last word, then she breaks into a wide grin, teeth showing, the skin at the corners of her eyes crinkling. She tilts her head slightly to the right, the grin fading to a soft expression as her lips move and she speaks again, her voice warmer (S1): <d>[English] Like, I didn't want to leave.</d> Her mouth closes. She presses her lips together, then points her index finger at the camera and speaks with a playful tone, her lips forming each word (S1): <d>[English] So I'm not.</d> Her mouth closes, and the wide grin returns. With her free left hand she pushes the windblown hair behind her ear, her gold bracelet sliding down her wrist. She raises her free hand in a quick peace sign next to her cheek, her smile widening, and laughs — a short, genuine laugh, her mouth opening, shoulders shaking once, her eyes squeezing shut for a beat, then opening again. She drops the peace sign and brings the phone slightly closer, her face growing larger in frame, the grin settling into a warm smile. The camera pushes in with small amplitude at slow speed as the phone draws closer.\n\noverall_soundscape: A warm breeze carries birdsong and the distant hum of a city at golden hour. The woman's laugh is clear and close to the phone mic — short, genuine, with a slight exhale. Loose hair rustles as she tucks it behind her ear, and the gold bracelet lightly clinks against itself on her wrist.\n\nnon_diegetic_music: N/A"
},
"class_type": "PrimitiveStringMultiline",
"_meta": {
"title": "Text Prompt"
}
},
"6006": {
"inputs": {
"lora_name": "minimax-h3\\minimax_h3_fl2v_turbo_4step_v1.1_768p_comfyui_bf16.safetensors",
"strength_model": 0.6,
"model": [
"215",
0
]
},
"class_type": "LoraLoaderModelOnly",
"_meta": {
"title": "Load LoRA"
}
},
"6007": {
"inputs": {
"shift_video": 12,
"shift_audio": 3,
"model": [
"223",
0
]
},
"class_type": "MiniMaxH3SigmaShift",
"_meta": {
"title": "ModelSamplingMiniMaxH3"
}
},
"6010": {
"inputs": {
"PowerLoraLoaderHeaderWidget": {
"type": "PowerLoraLoaderHeaderWidget"
},
"lora_1": {
"on": true,
"lora": "minimax-h3\\h3-realism-people-t2v-i2v-r2v(r34l1sm).safetensors",
"strength": 1
},
"➕ Add Lora": "",
"model": [
"6006",
0
],
"clip": [
"216",
0
]
},
"class_type": "Power Lora Loader (rgthree)",
"_meta": {
"title": "Power Lora Loader (rgthree)"
}
},
"6020": {
"inputs": {
"tau": 1.2,
"start_percent": 0.2,
"end_percent": 0.9,
"min_tokens": 4096,
"int8_qk": false,
"sink_conditioning": "exact_kv",
"morton": false,
"morton_curve": "3d",
"verbose": false,
"model": [
"6010",
0
]
},
"class_type": "SolAttnPatch",
"_meta": {
"title": "Patch Sol-Attn"
}
},
"6059": {
"inputs": {
"anything": [
"6060",
0
]
},
"class_type": "easy clearCacheAll",
"_meta": {
"title": "Clear Cache All"
}
},
"6060": {
"inputs": {
"anything": [
"210",
0
]
},
"class_type": "easy cleanGpuUsed",
"_meta": {
"title": "Clean VRAM Used"
}
},
"6062:5570": {
"inputs": {
"vae_name": "ltx-2.5\\ltx-2.5-audio-vae-bf16.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "Load VAE - Audio"
}
},
"6062:5571": {
"inputs": {
"vae_name": "ltx-2.5\\ltx-2.5-video-vae-bf16.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "Load VAE - Video"
}
},
"6062:5569": {
"inputs": {
"unet_name": "ltx-2.5\\ltx-2.5-22b-distilled-transformer-comfy-int8-convrot.safetensors",
"weight_dtype": "default"
},
"class_type": "UNETLoader",
"_meta": {
"title": "Load Diffusion Model"
}
},
"6062:5577": {
"inputs": {
"model_name": "ltx-2.5\\ltx-2.5-latent-spatial-upscaler-x2-bf16-1.0.safetensors"
},
"class_type": "LatentUpscaleModelLoader",
"_meta": {
"title": "Load Latent Upscale Model"
}
},
"6062:5574": {
"inputs": {
"clip_name": "gemma4-12b-with-proj-ltx-2.5-comfy-int8-convrot.safetensors",
"type": "ltxv",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": {
"title": "Load CLIP - Text Encoder"
}
},
"6065:2612": {
"inputs": {
"text": "",
"clip": [
"6062:5574",
0
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP Text Encode (Negative Prompt)"
}
},
"6065:2483": {
"inputs": {
"text": [
"6005",
0
],
"clip": [
"6062:5574",
0
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP Text Encode (Positive Prompt)"
}
},
"6065:1241": {
"inputs": {
"frame_rate": [
"5986",
0
],
"positive": [
"6065:2483",
0
],
"negative": [
"6065:2612",
0
]
},
"class_type": "LTXVConditioning",
"_meta": {
"title": "LTXVConditioning"
}
},
"6061:4985": {
"inputs": {
"sigmas": "0.85, 0.7250, 0.4219, 0.0"
},
"class_type": "ManualSigmas",
"_meta": {
"title": "ManualSigmas"
}
},
"6061:4976": {
"inputs": {
"sampler_name": "euler"
},
"class_type": "KSamplerSelect",
"_meta": {
"title": "KSamplerSelect"
}
},
"6061:4964": {
"inputs": {
"cfg": 1,
"model": [
"6062:5569",
0
],
"positive": [
"6065:1241",
0
],
"negative": [
"6065:1241",
1
]
},
"class_type": "CFGGuider",
"_meta": {
"title": "CFG Guider"
}
},
"6061:4967": {
"inputs": {
"noise_seed": 42
},
"class_type": "RandomNoise",
"_meta": {
"title": "RandomNoise"
}
},
"6061:4973": {
"inputs": {
"av_latent": [
"6061:4971",
0
]
},
"class_type": "LTXVSeparateAVLatent",
"_meta": {
"title": "LTXVSeparateAVLatent"
}
},
"6061:4971": {
"inputs": {
"noise": [
"6061:4967",
0
],
"guider": [
"6061:4964",
0
],
"sampler": [
"6061:4976",
0
],
"sigmas": [
"6061:4985",
0
],
"latent_image": [
"6061:4969",
0
]
},
"class_type": "SamplerCustomAdvanced",
"_meta": {
"title": "SamplerCustomAdvanced"
}
},
"6061:4969": {
"inputs": {
"video_latent": [
"6061:4970",
0
],
"audio_latent": [
"6061:6074",
0
]
},
"class_type": "LTXVConcatAVLatent",
"_meta": {
"title": "LTXVConcatAVLatent"
}
},
"6061:6072": {
"inputs": {
"value": 0,
"width": [
"6061:6073",
0
],
"height": [
"6061:6073",
1
]
},
"class_type": "SolidMask",
"_meta": {
"title": "Create Solid Mask"
}
},
"6061:4975": {
"inputs": {
"samples": [
"6061:6070",
0
],
"upscale_model": [
"6062:5577",
0
],
"vae": [
"6062:5571",
0
]
},
"class_type": "LTXVLatentUpsampler",
"_meta": {
"title": "LTXVLatentUpsampler"
}
},
"6061:6070": {
"inputs": {
"pixels": [
"6059",
0
],
"vae": [
"6062:5571",
0
]
},
"class_type": "VAEEncode",
"_meta": {
"title": "VAE Encode"
}
},
"6061:4970": {
"inputs": {
"strength": 1,
"bypass": true,
"vae": [
"6062:5571",
0
],
"image": [
"6059",
0
],
"latent": [
"6061:4975",
0
]
},
"class_type": "LTXVImgToVideoInplace",
"_meta": {
"title": "LTXVImgToVideoInplace"
}
},
"6061:6074": {
"inputs": {
"samples": [
"6061:6075",
0
],
"mask": [
"6061:6072",
0
]
},
"class_type": "SetLatentNoiseMask",
"_meta": {
"title": "Set Latent Noise Mask"
}
},
"6061:6073": {
"inputs": {
"image": [
"6059",
0
]
},
"class_type": "GetImageSize",
"_meta": {
"title": "Get Image Size"
}
},
"6061:6075": {
"inputs": {
"audio": [
"209",
0
],
"audio_vae": [
"6062:5570",
0
]
},
"class_type": "LTXVAudioVAEEncode",
"_meta": {
"title": "LTXV Audio VAE Encode"
}
},
"6063:5538": {
"inputs": {
"tile_size": 512,
"overlap": 64,
"temporal_size": 128,
"temporal_overlap": 32,
"samples": [
"6061:4973",
0
],
"vae": [
"6062:5571",
0
]
},
"class_type": "VAEDecodeTiled",
"_meta": {
"title": "VAE Decode (Tiled)"
}
},
"6063:4848": {
"inputs": {
"samples": [
"6061:4973",
1
],
"audio_vae": [
"6062:5570",
0
]
},
"class_type": "LTXVAudioVAEDecode",
"_meta": {
"title": "LTXV Audio VAE Decode"
}
}
}
@@ -0,0 +1,136 @@
{
"1": {
"inputs": {
"UNKNOWN": "qwen-image-2.1-Q4_K_M.gguf"
},
"_meta": {
"title": "Unet Loader (GGUF)"
}
},
"2": {
"inputs": {
"clip_name": "qwen3vl_8b_int8_convrot.safetensors",
"type": "qwen_image",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": {
"title": "加载CLIP"
}
},
"3": {
"inputs": {
"vae_name": "qwen_image_2.1_vae_bf16.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "加载VAE"
}
},
"4": {
"inputs": {
"prompt": "参考角色五官,重新生成图片:高细节皮肤质感",
"negative_prompt": "模糊,低质量,文字乱码,变形",
"resolution": 1024,
"clip": [
"2",
0
],
"images.image_1": [
"10",
0
],
"vae": [
"3",
0
]
},
"class_type": "TextEncodeQwenImage21",
"_meta": {
"title": "Text Encode Qwen Image 2.1"
}
},
"5": {
"inputs": {
"width": 1024,
"height": 1024,
"batch_size": 1
},
"class_type": "EmptyLatentImage",
"_meta": {
"title": "空Latent图像"
}
},
"6": {
"inputs": {
"seed": 278835418249884,
"steps": 25,
"cfg": 1,
"sampler_name": "euler",
"scheduler": "simple",
"denoise": 1,
"positive": [
"4",
0
],
"negative": [
"4",
1
],
"latent_image": [
"4",
2
]
},
"class_type": "KSampler",
"_meta": {
"title": "K采样器"
}
},
"7": {
"inputs": {
"samples": [
"6",
0
],
"vae": [
"3",
0
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAE解码"
}
},
"8": {
"inputs": {
"filename_prefix": "qwen_image",
"images": [
"7",
0
]
},
"class_type": "SaveImage",
"_meta": {
"title": "保存图像"
}
},
"9": {
"inputs": {
"UNKNOWN": "auto"
},
"_meta": {
"title": "Patch Sage Attention KJ"
}
},
"10": {
"inputs": {
"image": "3f24a075cd7b4118829be1b222ca1ab9.png"
},
"class_type": "LoadImage",
"_meta": {
"title": "加载图像"
}
}
}
@@ -0,0 +1,126 @@
{
"3": {
"inputs": {
"seed": 761215360232465,
"steps": 20,
"cfg": 2.5,
"sampler_name": "euler",
"scheduler": "simple",
"denoise": 1,
"model": ["75", 0],
"positive": ["76", 0],
"negative": ["77", 0],
"latent_image": ["90", 0]
},
"class_type": "KSampler",
"_meta": { "title": "KSampler" }
},
"8": {
"inputs": {
"samples": ["3", 0],
"vae": ["39", 0]
},
"class_type": "VAEDecode",
"_meta": { "title": "VAE Decode" }
},
"37": {
"inputs": {
"unet_name": "qwen_image_edit_2509_fp8_e4m3fn.safetensors",
"weight_dtype": "default"
},
"class_type": "UNETLoader",
"_meta": { "title": "Load Diffusion Model" }
},
"38": {
"inputs": {
"clip_name": "qwen_2.5_vl_7b_fp8_scaled.safetensors",
"type": "qwen_image",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": { "title": "Load CLIP" }
},
"39": {
"inputs": {
"vae_name": "qwen_image_vae.safetensors"
},
"class_type": "VAELoader",
"_meta": { "title": "Load VAE" }
},
"60": {
"inputs": {
"filename_prefix": "ComfyUI",
"images": ["8", 0]
},
"class_type": "SaveImage",
"_meta": { "title": "Save Image" }
},
"75": {
"inputs": {
"shift": 3,
"model": ["37", 0]
},
"class_type": "ModelSamplingAuraFlow",
"_meta": { "title": "ModelSamplingAuraFlow" }
},
"76": {
"inputs": {
"prompt": "把背景换成夜晚的城市街道",
"clip": ["38", 0],
"vae": ["39", 0],
"image1": ["93", 0]
},
"class_type": "TextEncodeQwenImageEditPlus",
"_meta": { "title": "TextEncodeQwenImageEditPlus" }
},
"77": {
"inputs": {
"prompt": "",
"clip": ["38", 0],
"vae": ["39", 0],
"image1": ["93", 0]
},
"class_type": "TextEncodeQwenImageEditPlus",
"_meta": { "title": "TextEncodeQwenImageEditPlus" }
},
"78": {
"inputs": {
"image": "example.png"
},
"class_type": "LoadImage",
"_meta": { "title": "Load Image" }
},
"88": {
"inputs": {
"pixels": ["93", 0],
"vae": ["39", 0]
},
"class_type": "VAEEncode",
"_meta": { "title": "VAE Encode" }
},
"90": {
"inputs": {
"samples": ["88", 0],
"mask": ["95", 0]
},
"class_type": "SetLatentNoiseMask",
"_meta": { "title": "Set Latent Noise Mask" }
},
"93": {
"inputs": {
"upscale_method": "lanczos",
"megapixels": 1,
"image": ["78", 0]
},
"class_type": "ImageScaleToTotalPixels",
"_meta": { "title": "Scale Image to Total Pixels" }
},
"95": {
"inputs": {
"image": "mask.png",
"channel": "alpha"
},
"class_type": "LoadImageMask",
"_meta": { "title": "Load Image (as Mask)" }
}
}
@@ -0,0 +1,91 @@
{
"3": {
"inputs": {
"seed": 1125488487853216,
"steps": 20,
"cfg": 2.5,
"sampler_name": "euler",
"scheduler": "simple",
"denoise": 1,
"model": ["66", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["58", 0]
},
"class_type": "KSampler",
"_meta": { "title": "KSampler" }
},
"6": {
"inputs": {
"text": "一只橘猫坐在窗台上,午后阳光,电影感",
"clip": ["38", 0]
},
"class_type": "CLIPTextEncode",
"_meta": { "title": "CLIP Text Encode (Positive Prompt)" }
},
"7": {
"inputs": {
"text": "",
"clip": ["38", 0]
},
"class_type": "CLIPTextEncode",
"_meta": { "title": "CLIP Text Encode (Negative Prompt)" }
},
"8": {
"inputs": {
"samples": ["3", 0],
"vae": ["39", 0]
},
"class_type": "VAEDecode",
"_meta": { "title": "VAE Decode" }
},
"37": {
"inputs": {
"unet_name": "qwen_image_fp8_e4m3fn.safetensors",
"weight_dtype": "default"
},
"class_type": "UNETLoader",
"_meta": { "title": "Load Diffusion Model" }
},
"38": {
"inputs": {
"clip_name": "qwen_2.5_vl_7b_fp8_scaled.safetensors",
"type": "qwen_image",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": { "title": "Load CLIP" }
},
"39": {
"inputs": {
"vae_name": "qwen_image_vae.safetensors"
},
"class_type": "VAELoader",
"_meta": { "title": "Load VAE" }
},
"58": {
"inputs": {
"width": 1328,
"height": 1328,
"batch_size": 1
},
"class_type": "EmptySD3LatentImage",
"_meta": { "title": "EmptySD3LatentImage" }
},
"60": {
"inputs": {
"filename_prefix": "ComfyUI",
"images": ["8", 0]
},
"class_type": "SaveImage",
"_meta": { "title": "Save Image" }
},
"66": {
"inputs": {
"shift": 3.1,
"model": ["37", 0]
},
"class_type": "ModelSamplingAuraFlow",
"_meta": { "title": "ModelSamplingAuraFlow" }
}
}
@@ -0,0 +1,301 @@
{
"1": {
"inputs": {
"value": "PASTE YOUR PROMPT HERE. Use the MiniMax-H3 prompt contract (integrated_multimodal_description / overall_soundscape / non_diegetic_music). The previews in this repo were made with a ten-shot subway prompt posted by a community member; it is not redistributed here."
},
"class_type": "PrimitiveStringMultiline",
"_meta": {
"title": "PROMPT (feeds every stage)"
}
},
"92": {
"inputs": {
"filename_prefix": "video/01_reference_4step_sla",
"format": "auto",
"format.codec": "auto",
"codec": "auto",
"video": [
"130",
0
]
},
"class_type": "SaveVideo",
"_meta": {
"title": "REFERENCE 2026-08-29: fused ref-delta bake + SLA 0.9/64, res_multistep/simple/4, shift 12/3, 1152x640, 243 f, seed 42 -> subway_bake_sla_00001_.mp4"
}
},
"115": {
"inputs": {
"aspect_ratio": "16:9 (Widescreen)",
"megapixels": 0.7,
"multiple": 32
},
"class_type": "ResolutionSelector",
"_meta": {
"title": "ResolutionSelector"
}
},
"116": {
"inputs": {
"expression": "a"
},
"class_type": "ComfyMathExpression",
"_meta": {
"title": "ComfyMathExpression"
}
},
"119": {
"inputs": {
"vae_name": "minimax_h3_video_vae_int8_convrot.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "VAELoader"
}
},
"120": {
"inputs": {
"vae_name": "minimax_h3_audio_vae_fp32.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "VAELoader"
}
},
"121": {
"inputs": {
"samples": [
"125",
0
],
"vae": [
"120",
0
]
},
"class_type": "VAEDecodeAudio",
"_meta": {
"title": "VAEDecodeAudio"
}
},
"122": {
"inputs": {
"samples": [
"125",
0
],
"vae": [
"119",
0
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAEDecode"
}
},
"124": {
"inputs": {
"scheduler": "simple",
"steps": 4,
"denoise": 1,
"model": [
"142",
0
]
},
"class_type": "BasicScheduler",
"_meta": {
"title": "BasicScheduler"
}
},
"125": {
"inputs": {
"noise": [
"129",
0
],
"guider": [
"126",
0
],
"sampler": [
"135",
0
],
"sigmas": [
"124",
0
],
"latent_image": [
"131",
1
]
},
"class_type": "SamplerCustomAdvanced",
"_meta": {
"title": "SamplerCustomAdvanced"
}
},
"126": {
"inputs": {
"model": [
"142",
0
],
"conditioning": [
"131",
0
]
},
"class_type": "BasicGuider",
"_meta": {
"title": "BasicGuider"
}
},
"127": {
"inputs": {
"unet_name": "minimax_h3_fused_refdelta_r1024_turbo8_mystic07_int8_convrot.safetensors",
"weight_dtype": "default"
},
"class_type": "UNETLoader",
"_meta": {
"title": "UNETLoader"
}
},
"128": {
"inputs": {
"clip_name": "qwen3vl_32b_heretic_minimax_h3_nvfp4.safetensors",
"type": "minimax",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": {
"title": "CLIPLoader"
}
},
"129": {
"inputs": {
"noise_seed": 42
},
"class_type": "RandomNoise",
"_meta": {
"title": "RandomNoise"
}
},
"130": {
"inputs": {
"fps": 24,
"bit_depth": 8,
"color_space": "sRGB",
"codec": "none",
"images": [
"122",
0
],
"audio": [
"121",
0
]
},
"class_type": "CreateVideo",
"_meta": {
"title": "CreateVideo"
}
},
"131": {
"inputs": {
"prompt": [
"1",
0
],
"width": [
"115",
0
],
"height": [
"115",
1
],
"length": [
"132",
1
],
"clip": [
"128",
0
],
"vae": [
"119",
0
]
},
"class_type": "MiniMaxH3ImageToVideo",
"_meta": {
"title": "MiniMaxH3ImageToVideo"
}
},
"132": {
"inputs": {
"expression": "max(5, round(a * 24)) + (5 - (max(5, round(a * 24)) % 17)) % 17",
"values.a": [
"133",
0
]
},
"class_type": "ComfyMathExpression",
"_meta": {
"title": "ComfyMathExpression"
}
},
"133": {
"inputs": {
"value": 10
},
"class_type": "PrimitiveFloat",
"_meta": {
"title": "PrimitiveFloat"
}
},
"135": {
"inputs": {
"sampler_name": "res_multistep"
},
"class_type": "KSamplerSelect",
"_meta": {
"title": "KSamplerSelect"
}
},
"142": {
"inputs": {
"shift_video": 12,
"shift_audio": 3,
"model": [
"900",
0
]
},
"class_type": "MiniMaxH3SigmaShift",
"_meta": {
"title": "MiniMaxH3SigmaShift"
}
},
"900": {
"inputs": {
"sparsity_ratio": 0.9,
"block_size": "64",
"min_seq_len": 8192,
"dense_last_steps": 0,
"protect_audio": true,
"enabled": true,
"model": [
"127",
0
]
},
"class_type": "H3SLAAttention",
"_meta": {
"title": "H3 SLA Attention 0.9/64 (their graph)"
}
}
}
@@ -0,0 +1,137 @@
{
"13": {
"inputs": {
"aspect_ratio": "1:1 (Square)",
"megapixels": 1,
"multiple": 8
},
"class_type": "ResolutionSelector",
"_meta": {
"title": "分辨率选择器"
}
},
"461": {
"inputs": {
"filename_prefix": "Qwen_image_2.1",
"format": "png",
"format.bit_depth": "8-bit",
"format.input_color_space": "sRGB",
"images": [
"459:457",
0
]
},
"class_type": "SaveImageAdvanced",
"_meta": {
"title": "保存图像(高级)"
}
},
"459:451": {
"inputs": {
"unet_name": "qwen_image_2.1_int8_convrot.safetensors",
"weight_dtype": "default"
},
"class_type": "UNETLoader",
"_meta": {
"title": "UNet加载器"
}
},
"459:452": {
"inputs": {
"prompt": "Greyscale fashion editorial portrait of an avant-garde woman tilted upwards in profile, captured with striking high-contrast chiaroscuro lighting. She wears oversized, thick-rimmed circular black sunglasses with matte dark lenses, concealing her upward gaze. Her attire merges high couture with tactical abstraction: a high-necked structural dress tailored from heavyweight fabric featuring a disrupted optical camouflage pattern—interlocking oversized polka dots, pixelated stippling, and organic contour blotches in stark monochrome that mimic military disruptive patterns in a refined runway style. Around her shoulders, asymmetric pleated webbing and tailored cargo straps are integrated seamlessly into the garment’s architecture. In her gloved hand, she clutches a structured matte-leather geometric handbag accented with subtle tactical buckles, utility stitching, and dark brushed-metal hardware. Her skin texture is rendered with hyper-detailed clarity, sculpted by intense, razor-sharp studio key light and deep graphite shadows. The striking background is a vivid mixed-media graphic collage, composed of flat vibrant lime green planes, stark black and white overlapping circles, segmented semi-circles with bold zebra striping, dense micro-halftone dot matrices, and vector topographic contour lines. The clash between monochromatic disruptive camo elements on her clothing and the saturated, retro-futuristic pop-art backdrop creates an intense visual dissonance, emphasizing sharp silhouettes, paper cut-out edges, dynamic compositional balance, and clean graphic novel textures. Absolutely no text, no typography, no letters, no words, no numbers, no logos, no watermark, no captions, pure imagery only.",
"negative_prompt": "",
"resolution": 1024,
"clip": [
"459:453",
0
]
},
"class_type": "TextEncodeQwenImage21",
"_meta": {
"title": "Text Encode Qwen Image 2.1"
}
},
"459:453": {
"inputs": {
"clip_name": "qwen3vl_8b_int8_convrot.safetensors",
"type": "qwen_image",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": {
"title": "加载CLIP"
}
},
"459:454": {
"inputs": {
"vae_name": "qwen_image_2.1_vae_bf16.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "加载VAE"
}
},
"459:456": {
"inputs": {
"width": [
"13",
0
],
"height": [
"13",
1
],
"batch_size": 1
},
"class_type": "EmptyLatentImage",
"_meta": {
"title": "空Latent图像"
}
},
"459:457": {
"inputs": {
"samples": [
"459:458",
0
],
"vae": [
"459:454",
0
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAE解码"
}
},
"459:458": {
"inputs": {
"seed": 593103825222985,
"steps": 25,
"cfg": 1,
"sampler_name": "euler",
"scheduler": "simple",
"denoise": 1,
"model": [
"459:451",
0
],
"positive": [
"459:452",
0
],
"negative": [
"459:452",
1
],
"latent_image": [
"459:456",
0
]
},
"class_type": "KSampler",
"_meta": {
"title": "K采样器"
}
}
}
@@ -0,0 +1,36 @@
"""Workflow uniqueness is independent of the selected task skills."""
import pytest
from pydantic import ValidationError
from calliope.routers.agent import MessageCreate
def workflow(n=1):
return {"type": "workflow", "id": n, "name": f"workflow-{n}", "kind": "image"}
def skill(n=1):
return {"type": "skill", "name": f"skill-{n}"}
def test_workflow_and_skills_can_be_sent_together():
message = MessageCreate(content="Create an image", mentions=[workflow(), *[skill(i) for i in range(4)]])
assert len(message.mentions) == 5
assert message.mentions[0].id == 1
def test_legacy_single_workflow_is_accepted():
assert MessageCreate(mentions=[workflow()]).mentions[0].type == "workflow"
def test_skills_without_workflow_are_accepted():
assert len(MessageCreate(mentions=[skill(i) for i in range(4)]).mentions) == 4
def test_second_workflow_is_still_rejected():
with pytest.raises(ValidationError, match="Only one @workflow"):
MessageCreate(mentions=[workflow(), skill(), workflow(2)])
def test_fifth_skill_is_rejected():
with pytest.raises(ValidationError, match="At most four skill"):
MessageCreate(mentions=[skill(i) for i in range(5)])
+202
View File
@@ -0,0 +1,202 @@
"""Named-agent sessions (HEL-613): the five AIGC roles. agent_key is stamped
at creation, filters the session list, injects a per-agent persona + agent-chat
mode section into the assembled system prompt, and routes the main loop's LLM
through the agent's bound role (models page 智能体默认模型 panel)."""
from __future__ import annotations
import asyncio
from calliope.agent.harness.registry import ToolContext
# ─────────────────────────────────────────────────────────────────────────
# Registry
# ─────────────────────────────────────────────────────────────────────────
def test_agents_endpoint_lists_five_roles(client):
agents = client.get("/api/agent/agents").json()
assert [a["key"] for a in agents] == [
"script",
"style",
"storyboard",
"assets",
"shot",
]
names = {a["name"] for a in agents}
assert names == {"剧本创作", "视觉设计", "分镜设计", "素材资产", "镜头脚本"}
# LLM roles reuse the models-page five-role binding surface.
assert {a["llm_role"] for a in agents} == {"story", "style", "script", "assets", "video"}
# Personas are server-side — never shipped to the browser.
assert all("persona" not in a for a in agents)
assert all(isinstance(a.get("skill_count"), int) for a in agents)
# ─────────────────────────────────────────────────────────────────────────
# Session create / list semantics
# ─────────────────────────────────────────────────────────────────────────
def test_create_session_with_agent_key(client):
s = client.post("/api/agent/sessions", json={"agent_key": "style"}).json()
assert s["agent_key"] == "style"
assert s["origin"] == "chat"
def test_agent_key_rejects_unknown_value(client):
r = client.post("/api/agent/sessions", json={"agent_key": "director"})
assert r.status_code == 422
def test_agent_sessions_hidden_from_generic_list(client):
plain = client.post("/api/agent/sessions", json={"title": "canvas chat"}).json()
named = client.post("/api/agent/sessions", json={"agent_key": "script"}).json()
listed = client.get("/api/agent/sessions").json()
ids = {s["id"] for s in listed}
assert plain["id"] in ids
assert named["id"] not in ids
def test_agent_key_filter_returns_only_that_agent(client):
a = client.post("/api/agent/sessions", json={"agent_key": "script"}).json()
client.post("/api/agent/sessions", json={"agent_key": "style"})
client.post("/api/agent/sessions", json={"title": "plain"})
listed = client.get("/api/agent/sessions?agent_key=script").json()
assert {s["id"] for s in listed} == {a["id"]}
assert all(s["agent_key"] == "script" for s in listed)
def test_plain_session_has_no_agent_key(client):
s = client.post("/api/agent/sessions", json={}).json()
assert s["agent_key"] is None
# ─────────────────────────────────────────────────────────────────────────
# Prompt assembly: persona + mode per agent_key
# ─────────────────────────────────────────────────────────────────────────
def _assemble(agent_key: str | None) -> str:
from calliope.agent.harness import get_prompts
ctx = ToolContext(session_id=1, project_id=None, origin="chat", agent_key=agent_key)
return asyncio.run(get_prompts().assemble(ctx))
def test_persona_section_injected_per_agent(client):
prompt = _assemble("script")
assert "「剧本创作」" in prompt
assert "编剧" in prompt
assert "SESSION MODE: AGENT CHAT" in prompt
# The generic sandbox mode text must be replaced for named agents.
assert "GENERATING IN SANDBOX" not in prompt
def test_each_agent_gets_its_own_persona(client):
for key, marker in (
("style", "视觉"),
("storyboard", "分镜"),
("assets", "资产"),
("shot", "镜头脚本"),
):
assert marker in _assemble(key), key
def test_plain_prompt_unchanged(client):
prompt = _assemble(None)
assert "Calliope's production agent" in prompt
assert "SESSION MODE: SANDBOX" in prompt
assert "AGENT CHAT" not in prompt
def test_agent_chat_mentions_section_suppressed(client):
assert "Tagged workflows" not in _assemble("assets")
assert "Tagged workflows" in _assemble(None)
# ─────────────────────────────────────────────────────────────────────────
# LLM routing: the main loop uses the agent's bound role
# ─────────────────────────────────────────────────────────────────────────
class _RoleSpyClient:
instances: list["_RoleSpyClient"] = []
def __init__(self, role: str | None = None) -> None:
self.role = role
self.requests: list[list[dict]] = []
_RoleSpyClient.instances.append(self)
@classmethod
def for_role(cls, role: str, *, timeout: float = 120.0) -> "_RoleSpyClient":
return cls(role=role)
def chat_stream(self, messages, temperature=0.4, tools=None):
self.requests.append(list(messages))
return self
def __aiter__(self):
return self
async def __anext__(self):
raise StopAsyncIteration
async def close(self):
pass
def test_run_turn_uses_agent_llm_role_and_prompt(client):
"""End-to-end through run_turn with a scripted LLM: the client must be
built for the agent's role (story ← 剧本创作) and receive the per-agent
persona in the system message."""
import calliope.agent.harness.loop as loop_mod
session = client.post(
"/api/agent/sessions", json={"agent_key": "script", "title": "剧本会话"}
).json()
from calliope.agent.harness import log as session_log
session_log.append_event(
session["id"], session_log.USER_MESSAGE, {"content": "写个三幕式大纲"}
)
orig = loop_mod.LLMClient
_RoleSpyClient.instances = []
loop_mod.LLMClient = _RoleSpyClient
try:
ctx = ToolContext(
session_id=session["id"], project_id=None, origin="chat", agent_key="script"
)
asyncio.run(loop_mod.run_turn(ctx, []))
finally:
loop_mod.LLMClient = orig
assert _RoleSpyClient.instances, "no LLM client was built"
spy = _RoleSpyClient.instances[0]
assert spy.role == "story"
system = next(
(m for m in spy.requests[0] if m.get("role") == "system"), None
)
assert system is not None
assert "「剧本创作」" in system["content"]
assert "SESSION MODE: AGENT CHAT" in system["content"]
def test_plain_run_turn_keeps_main_role(client):
import calliope.agent.harness.loop as loop_mod
session = client.post("/api/agent/sessions", json={}).json()
from calliope.agent.harness import log as session_log
session_log.append_event(session["id"], session_log.USER_MESSAGE, {"content": "hi"})
orig = loop_mod.LLMClient
_RoleSpyClient.instances = []
loop_mod.LLMClient = _RoleSpyClient
try:
ctx = ToolContext(session_id=session["id"], project_id=None, origin="chat")
asyncio.run(loop_mod.run_turn(ctx, []))
finally:
loop_mod.LLMClient = orig
assert _RoleSpyClient.instances[0].role == "main"
@@ -374,6 +374,61 @@ def test_comfy_test_requires_url(client):
assert r.status_code == 400
def test_schemeless_url_is_format_error_everywhere(client):
"""HEL-619: a schemeless/garbled address is a 400 format error on every
entry point (probe + save), not a silent http:// prefix that later
surfaces as a DNS-classified probe failure."""
prev = _snapshot_settings()
try:
for url in ("192.168.0.1:8188", "http:192.168.0.1", "ht!tp://x", "http://a b.test/"):
r = client.post(
"/api/comfyui/endpoints/test", json={"base_url": url, "timeout_sec": 2}
)
assert r.status_code == 400, url
assert "http://" in r.json()["detail"] or "https://" in r.json()["detail"]
r = client.post(
"/api/comfyui/endpoints", json={"name": "无协议", "base_url": url}
)
assert r.status_code == 400, url
assert "无协议" not in [ep.get("name") for ep in settings.comfyui_endpoints or []]
finally:
for key, value in prev.items():
setattr(settings, key, value)
def test_save_endpoint_runs_ssrf_guard(client):
"""HEL-619: the SSRF guard applies on save (create + edit), not only on
the connectivity probe; loopback stays allowed."""
prev = _snapshot_settings()
try:
_clear_endpoints()
r = client.post(
"/api/comfyui/endpoints",
json={"name": "元数据", "base_url": "http://169.254.169.254/latest"},
)
assert r.status_code == 400
assert "禁止" in r.json()["detail"]
r = client.post(
"/api/comfyui/endpoints",
json={"name": "本机", "base_url": "http://127.0.0.1:8188"},
)
assert r.status_code == 200
eid = r.json()["endpoints"][-1]["id"]
r = client.patch(
f"/api/comfyui/endpoints/{eid}",
json={"base_url": "http://169.254.169.254/latest"},
)
assert r.status_code == 400
# the stored endpoint is untouched
stored = next(ep for ep in settings.comfyui_endpoints if ep["id"] == eid)
assert stored["base_url"] == "http://127.0.0.1:8188"
finally:
for key, value in prev.items():
setattr(settings, key, value)
# ----------------------------------------------------------------------
# workflow binding + job snapshots
# ----------------------------------------------------------------------
@@ -381,10 +436,21 @@ def test_comfy_test_requires_url(client):
def _import_workflow(client, name="H3 视频", kind="video", endpoint_id=None):
wf_json = {
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "model.safetensors"},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "placeholder prompt", "clip": ["4", 1]},
},
# HEL-636: a workflow needs a recognizable output node to classify a
# usage and stay enabled; without it generation is refused.
"9": {
"class_type": "VHS_VideoCombine",
"inputs": {"images": ["6", 0]},
"_meta": {"title": "Video Output (Output:video)"},
},
}
payload = {"name": name, "kind": kind, "workflow_json": wf_json}
if endpoint_id is not None:
@@ -531,6 +597,41 @@ def test_queue_enqueue_stamps_all_paths(client):
setattr(settings, key, value)
def test_workflow_delete_blocked_while_jobs_pending(client):
"""HEL-619: deleting a workflow with pending/running jobs is refused
with 409 instead of silently nulling the jobs' workflow_id."""
from calliope.db import get_db
from calliope.queue.manager import queue_manager
wf = _import_workflow(client).json()
project = client.post(
"/api/projects", json={"title": "删除保护测试", "idea": "x"}
).json()
# Pause the lifespan-started worker so the pending job STAYS pending.
queue_manager.paused = True
try:
queue_manager.enqueue(
project_id=project["id"], kind="video", workflow_id=wf["id"], payload={}
)
r = client.delete(f"/api/workflows/{wf['id']}")
assert r.status_code == 409
assert "排队" in r.json()["detail"]
finally:
queue_manager.paused = False
# job finished → deletion allowed again
conn = get_db(settings.db_path)
try:
conn.execute("UPDATE jobs SET status = 'done' WHERE project_id = ?", (project["id"],))
conn.commit()
finally:
conn.close()
r = client.delete(f"/api/workflows/{wf['id']}")
assert r.status_code == 200
assert r.json() == {"ok": True}
def test_settings_public_dict_masks_endpoint_tokens(client):
prev = _snapshot_settings()
try:
@@ -0,0 +1,594 @@
"""HEL-633: the three image-input workflows the user supplied — Qwen Image 2.1
图片编辑, MiniMax H3 首帧图生视频 (04) and H3 参考图生视频 (05, 1–9 refs).
Slots come from real wiring, every picked image is uploaded to ComfyUI and
lands in the node input it belongs to (in order, nothing stale), and a
broken export is refused before anything is queued."""
from __future__ import annotations
import json
import re
import threading
from http.server import ThreadingHTTPServer
from pathlib import Path
import pytest
from calliope.comfyui.lint import blocking_problem, workflow_warnings
from calliope.comfyui.parser import detect_untagged_inputs, parse_dynamic_inputs
from calliope.comfyui.patcher import patch_workflow
from calliope.comfyui.smart_fill import smart_fill_inputs
from calliope.comfyui.validate import validate_input_values
from calliope.config import settings
from tests.test_studio_generation import (
PNG_1PX,
_FakeComfyHandler,
_import_workflow,
_real_run,
_wait_done,
)
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
I2V = "h3_i2v_04_API.json" # 04_i2v_fl2v_4step_sla (1).json
REF = "h3_ref2va_05_API.json" # 05_ref2va_4step_sla (1).json
QWEN_EDIT = "qwen21_edit_sage_API.json" # qwen-image-2.1-图片编辑-sage加速.json
EXAMPLES = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
REF_KEY = "131@ref_images"
LAST_KEY = "131@last_frame"
def _load(name: str) -> dict:
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
def _by_node(inputs: list[dict]) -> dict[str, dict]:
return {i["nodeId"]: i for i in inputs}
def _ref_links(wf: dict, node_id: str = "131") -> list[tuple[str, str]]:
"""[(input name, LoadImage image value), …] in slot order."""
inputs = wf[node_id]["inputs"]
names = sorted(
(k for k in inputs if k.startswith("ref_images.ref_image_")),
key=lambda k: int(k.rsplit("_", 1)[1]),
)
return [(k, wf[inputs[k][0]]["inputs"]["image"]) for k in names]
# ---------------------------------------------------------------------------
# Slot detection
# ---------------------------------------------------------------------------
def test_i2v_exposes_required_first_frame_and_optional_last_frame():
"""HEL-671: the 04 export is H3 FL2VA — `last_frame` is unwired but real."""
inputs = parse_dynamic_inputs(_load(I2V))
media = [i for i in inputs if i["kind"] in ("image", "image_list", "video", "audio")]
assert media == [
{
"nodeId": "901",
"label": "首帧",
"role": "first_frame",
"kind": "image",
"defaultValue": None,
"required": True,
"auto": True,
},
{
"nodeId": LAST_KEY,
"label": "尾帧",
"role": "last_frame",
"kind": "image",
"defaultValue": None,
"required": False,
"auto": True,
},
]
def test_wired_last_frame_is_its_own_slot_not_a_second_virtual_one():
wf = _load(I2V)
wf["950"] = {"class_type": "LoadImage", "inputs": {"image": "last.png"}}
wf["131"]["inputs"]["last_frame"] = ["950", 0]
media = [i for i in parse_dynamic_inputs(wf) if i["kind"] == "image"]
assert [(i["nodeId"], i["role"], i["required"]) for i in media] == [
("901", "first_frame", True),
("950", "last_frame", True),
]
def test_first_frame_through_a_custom_loader_gets_no_last_frame_slot():
# Repo example: VHS_LoadImagePath → resize → first_frame; no slot pair to mirror.
wf = json.loads(
(EXAMPLES / "minimax_h3-Turbo_Image2Video_20260813_API.json").read_text(encoding="utf-8")
)
assert not any(i.get("role") == "last_frame" for i in parse_dynamic_inputs(wf))
def test_ref2v_exposes_one_group_of_one_to_nine_reference_images():
inputs = parse_dynamic_inputs(_load(REF))
media = [i for i in inputs if i["kind"] in ("image", "image_list", "video", "audio")]
assert len(media) == 1
group = media[0]
assert group["nodeId"] == REF_KEY
assert group["kind"] == "image_list"
assert group["role"] == "reference_image"
assert group["label"] == "参考图"
assert (group["min"], group["max"], group["required"]) == (1, 9, True)
# the parameters HEL-627 wired stay exposed
by_node = _by_node(inputs)
for key in ("1", "115@aspect_ratio", "115@megapixels", "124@steps", "124@denoise",
"129@noise_seed", "133"):
assert key in by_node, key
assert not any(i["kind"] == "video" for i in inputs)
def test_example_five_ref_workflow_folds_into_the_same_group():
# The repo example is (Input:…)-tagged and keeps its tagged slots; stripped
# of tags (as a plain ComfyUI export) its five loaders become one group.
wf = json.loads((EXAMPLES / "video_minimax_h3_r2v_5ref_API.json").read_text(encoding="utf-8"))
inputs = detect_untagged_inputs(wf)
groups = [i for i in inputs if i["kind"] == "image_list"]
assert [g["nodeId"] for g in groups] == ["178@ref_images"]
# its five LoadImages are the group, not five separate slots
assert not any(i["kind"] == "image" for i in inputs if i.get("role") != "mask")
def test_qwen_edit_exposes_input_image_real_resolution_and_hides_orphan_latent():
inputs = parse_dynamic_inputs(_load(QWEN_EDIT))
by_node = _by_node(inputs)
img = by_node["10"]
assert (img["kind"], img["role"], img["label"]) == ("image", "image", "输入图")
assert img["required"] is True
res = by_node["4@resolution"]
assert (res["role"], res["defaultValue"], res["min"], res["max"], res["step"]) == (
"resolution", 1024, 0, 4096, 32,
)
assert by_node["4@prompt"]["role"] == "prompt"
assert by_node["4@negative_prompt"]["role"] == "negative"
for key in ("6@seed", "6@steps", "6@cfg", "6@denoise"):
assert key in by_node
# EmptyLatentImage #5 feeds nothing — its width/height would do nothing
assert not any(i["nodeId"].startswith("5@") for i in inputs)
def test_unwired_load_image_gets_no_slot():
wf = _load(I2V)
wf["950"] = {
"class_type": "LoadImage",
"inputs": {"image": "last.png"},
"_meta": {"title": "LAST"},
}
assert "950" not in _by_node(parse_dynamic_inputs(wf))
# ---------------------------------------------------------------------------
# Broken exports
# ---------------------------------------------------------------------------
def test_broken_qwen_export_is_flagged_and_blocked():
warnings = workflow_warnings(_load(QWEN_EDIT))
errors = {(w["code"], w["node_id"]) for w in warnings if w["level"] == "error"}
assert errors == {
("missing_class_type", "1"),
("missing_class_type", "9"),
("missing_model_link", "6"),
}
assert ("orphan_latent", "5") in {(w["code"], w["node_id"]) for w in warnings}
msg = blocking_problem(_load(QWEN_EDIT))
assert msg and "Unet Loader (GGUF)" in msg and "重新导出" in msg
def test_h3_workflows_are_clean():
assert workflow_warnings(_load(I2V)) == []
assert workflow_warnings(_load(REF)) == []
# ---------------------------------------------------------------------------
# Patching 1 / 4 / 9 reference images
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("count", [1, 4, 9])
def test_patch_wires_each_reference_image_in_order(count):
base = _load(REF)
paths = [f"/assets/uploads/ref-{i}.png" for i in range(1, count + 1)]
wf = patch_workflow(base, {REF_KEY: paths})
links = _ref_links(wf)
assert links == [(f"ref_images.ref_image_{i}", p) for i, p in enumerate(paths)]
# every loader is a real, distinct LoadImage feeding node 131
loader_ids = [wf["131"]["inputs"][name][0] for name, _ in links]
assert len(set(loader_ids)) == count
assert all(wf[n]["class_type"] == "LoadImage" for n in loader_ids)
assert loader_ids[0] == "901"
# the base workflow is untouched
assert _ref_links(base) == [("ref_images.ref_image_0", "reference_1.png")]
def test_patch_shrinking_removes_stale_slots_and_loaders():
nine = patch_workflow(_load(REF), {REF_KEY: [f"/u/{i}.png" for i in range(9)]})
two = patch_workflow(nine, {REF_KEY: ["/u/b.png", "/u/a.png"]})
assert _ref_links(two) == [
("ref_images.ref_image_0", "/u/b.png"),
("ref_images.ref_image_1", "/u/a.png"),
]
load_images = [n for n, v in two.items() if v.get("class_type") == "LoadImage"]
assert len(load_images) == 2
assert not any("/u/8.png" in json.dumps(v) for v in two.values())
def test_single_slot_values_patch_first_frame_and_qwen_input():
i2v = patch_workflow(_load(I2V), {"901": "/assets/uploads/first.png"})
assert i2v["901"]["inputs"]["image"] == "/assets/uploads/first.png"
assert i2v["131"]["inputs"]["first_frame"] == ["901", 0]
qwen = patch_workflow(_load(QWEN_EDIT), {"10": "/assets/uploads/in.png", "4@resolution": 1536})
assert qwen["10"]["inputs"]["image"] == "/assets/uploads/in.png"
assert qwen["4"]["inputs"]["resolution"] == 1536
def test_patch_last_frame_adds_its_own_load_image():
base = _load(I2V)
wf = patch_workflow(base, {"901": "/u/first.png", LAST_KEY: "/u/last.png"})
node = wf["131"]["inputs"]
assert node["first_frame"] == ["901", 0]
last_id = node["last_frame"][0]
assert last_id not in base and node["last_frame"] == [last_id, 0]
assert wf[last_id]["class_type"] == "LoadImage"
assert wf[last_id]["inputs"] == {"image": "/u/last.png"}
assert wf["901"]["inputs"]["image"] == "/u/first.png"
# base untouched; patching the result again rewires nothing
assert "last_frame" not in base["131"]["inputs"]
again = patch_workflow(wf, {LAST_KEY: "/u/other.png"})
assert again["131"]["inputs"]["last_frame"] == [last_id, 0]
def test_patch_without_last_frame_leaves_first_frame_only_graph():
for values in ({"901": "/u/first.png"}, {"901": "/u/first.png", LAST_KEY: ""}):
wf = patch_workflow(_load(I2V), values)
assert "last_frame" not in wf["131"]["inputs"]
assert sorted(wf) == sorted(_load(I2V))
def test_legacy_job_values_keyed_by_load_image_still_patch():
"""Jobs queued before HEL-633 carry the LoadImage id, not the group key."""
wf = patch_workflow(_load(REF), {"901": "/assets/uploads/old.png"})
assert _ref_links(wf) == [("ref_images.ref_image_0", "/assets/uploads/old.png")]
def test_smart_fill_hands_reference_list_to_the_group():
inputs = parse_dynamic_inputs(_load(REF))
refs = [f"/r/{i}.png" for i in range(12)]
values = smart_fill_inputs(inputs, prompt="p", ref_images=refs)
assert values[REF_KEY] == refs[:9]
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
@pytest.fixture
def uploads_dir(client):
d = settings.assets_dir / "uploads"
d.mkdir(parents=True, exist_ok=True)
return d
def _pngs(d: Path, n: int) -> list[str]:
out = []
for i in range(n):
p = d / f"ref{i + 1}.png"
p.write_bytes(PNG_1PX)
out.append(str(p))
return out
def test_validate_reference_count_limits(uploads_dir):
schema = parse_dynamic_inputs(_load(REF))
base = {"1": "prompt"}
for n in (1, 4, 9):
clean, errs = validate_input_values(schema, {**base, REF_KEY: _pngs(uploads_dir, n)})
assert errs == [] and len(clean[REF_KEY]) == n
_, errs = validate_input_values(schema, {**base, REF_KEY: _pngs(uploads_dir, 10)})
assert errs == ["参考图最多 9 张(当前 10 张),请删除多余的图片"]
_, errs = validate_input_values(schema, base)
assert errs == ["需要至少 1 张参考图"]
_, errs = validate_input_values(schema, {**base, REF_KEY: []})
assert errs == ["需要至少 1 张参考图"]
def test_validate_rejects_bad_reference_files(uploads_dir):
schema = parse_dynamic_inputs(_load(REF))
gif = uploads_dir / "anim.gif"
gif.write_bytes(b"GIF89a")
ok = _pngs(uploads_dir, 1)[0]
_, errs = validate_input_values(schema, {"1": "p", REF_KEY: [ok, str(gif)]})
assert errs == ["参考图 2:格式不支持,请使用 PNG / JPG / WebP"]
_, errs = validate_input_values(schema, {"1": "p", REF_KEY: ["/etc/passwd.png"]})
assert errs == ["参考图 1:只能使用在本页上传或从生成结果选入的图片"]
_, errs = validate_input_values(schema, {"1": "p", REF_KEY: [str(uploads_dir / "gone.png")]})
assert errs == ["参考图 1:文件已不存在,请重新上传"]
def test_validate_requires_first_frame_and_input_image():
_, errs = validate_input_values(parse_dynamic_inputs(_load(I2V)), {"1": "p"})
assert errs == ["请上传首帧"]
_, errs = validate_input_values(
parse_dynamic_inputs(_load(I2V)), {"1": "p", LAST_KEY: "/u/l.png"}
)
assert errs == ["请上传首帧"]
clean, errs = validate_input_values(
parse_dynamic_inputs(_load(I2V)), {"1": "p", "901": "/u/f.png"}
)
assert errs == [] and LAST_KEY not in clean
_, errs = validate_input_values(parse_dynamic_inputs(_load(QWEN_EDIT)), {"4@prompt": "p"})
assert errs == ["请上传输入图"]
# ---------------------------------------------------------------------------
# Full chain against a fake ComfyUI
# ---------------------------------------------------------------------------
class _NamedUploadHandler(_FakeComfyHandler):
"""/upload/image answers with the uploaded file's own name, like ComfyUI."""
def do_POST(self): # noqa: N802 — BaseHTTPRequestHandler hook name
if self.path != "/upload/image":
super().do_POST()
return
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length) if length else b""
self.server.requests.append(self.path)
self.server.uploads.append(body)
m = re.search(rb'filename="([^"]+)"', body)
name = m.group(1).decode() if m else "x.png"
self.server.upload_names.append(name)
self._json(200, {"name": name, "subfolder": "calliope", "type": "input"})
@pytest.fixture
def named_comfy():
server = ThreadingHTTPServer(("127.0.0.1", 0), _NamedUploadHandler)
server.requests = []
server.prompts = []
server.uploads = []
server.upload_names = []
server.history = {}
server.fail_prompt = False
server.out_name = "ref2v_00001_.mp4"
server.outputs_key = "images"
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server
finally:
server.shutdown()
server.server_close()
def _upload(client, name: str) -> str:
r = client.post("/api/playground/uploads", files={"file": (name, PNG_1PX, "image/png")})
assert r.status_code == 200, r.text
return r.json()["path"]
def _generate(client, wf_id: int, values: dict, source: str = "studio-video"):
return client.post(
"/api/playground/generate",
json={
"workflow_id": wf_id,
"input_values": values,
"source": source,
"endpoint_id": settings.comfyui_active_id,
},
)
@pytest.mark.parametrize("count", [1, 4, 9])
def test_ref2v_chain_uploads_every_image_and_wires_it_in_order(client, named_comfy, count):
with _real_run(client, named_comfy):
wf_id = _import_workflow(client, "H3 参考生视频", "video", _load(REF))
paths = [_upload(client, f"角色{i}.png") for i in range(1, count + 1)]
r = _generate(
client,
wf_id,
{"1": "<Subject 1> 走进雨夜码头", REF_KEY: paths, "124@steps": 4, "133": 2},
)
assert r.status_code == 200, r.text
job = _wait_done(client, r.json()["job"]["id"])
assert job["status"] == "done", job.get("error")
assert job["payload"]["input_values"][REF_KEY] == paths
assert len(named_comfy.prompts) == 1
prompt = named_comfy.prompts[0]["prompt"]
expected = [f"calliope/{Path(p).name}" for p in paths]
assert [img for _k, img in _ref_links(prompt)] == expected
assert named_comfy.upload_names == [Path(p).name for p in paths]
assert prompt["1"]["inputs"]["value"] == "<Subject 1> 走进雨夜码头"
assert prompt["124"]["inputs"]["steps"] == 4
assert prompt["133"]["inputs"]["value"] == 2
# no leftover default reference and no unconsumed LoadImage
assert "reference_1.png" not in json.dumps(prompt)
loaders = {n for n, v in prompt.items() if v["class_type"] == "LoadImage"}
wired = {v[0] for k, v in prompt["131"]["inputs"].items() if k.startswith("ref_images.")}
assert loaders == wired
def test_ref2v_replace_and_delete_leave_no_stale_image(client, named_comfy):
with _real_run(client, named_comfy):
wf_id = _import_workflow(client, "H3 参考生视频", "video", _load(REF))
a, b, c = (_upload(client, n) for n in ("a.png", "b.png", "c.png"))
r = _generate(client, wf_id, {"1": "x", REF_KEY: [a, b, c]})
assert _wait_done(client, r.json()["job"]["id"])["status"] == "done"
# user replaced #2 with d and deleted #3
d = _upload(client, "d.png")
r = _generate(client, wf_id, {"1": "x", REF_KEY: [a, d]})
assert _wait_done(client, r.json()["job"]["id"])["status"] == "done"
second = named_comfy.prompts[1]["prompt"]
assert [img for _k, img in _ref_links(second)] == [
f"calliope/{Path(a).name}",
f"calliope/{Path(d).name}",
]
dumped = json.dumps(second)
assert Path(b).name not in dumped and Path(c).name not in dumped
def test_ref2v_ten_images_rejected_before_queue(client, named_comfy):
with _real_run(client, named_comfy):
wf_id = _import_workflow(client, "H3 参考生视频", "video", _load(REF))
paths = [_upload(client, f"r{i}.png") for i in range(10)]
r = _generate(client, wf_id, {"1": "x", REF_KEY: paths})
assert r.status_code == 400
assert "参考图最多 9 张" in r.json()["detail"]
r = _generate(client, wf_id, {"1": "x"})
assert r.status_code == 400
assert "需要至少 1 张参考图" in r.json()["detail"]
assert named_comfy.prompts == [] and named_comfy.upload_names == []
def test_i2v_chain_uploads_first_frame(client, named_comfy):
with _real_run(client, named_comfy):
wf_id = _import_workflow(client, "H3 图生视频", "video", _load(I2V))
r = _generate(client, wf_id, {"1": "镜头缓慢推近"})
assert r.status_code == 400 and "请上传首帧" in r.json()["detail"]
first = _upload(client, "首帧.png")
r = _generate(client, wf_id, {"1": "镜头缓慢推近", "901": first, "124@denoise": 0.8})
assert r.status_code == 200, r.text
assert _wait_done(client, r.json()["job"]["id"])["status"] == "done"
prompt = named_comfy.prompts[0]["prompt"]
assert prompt["901"]["inputs"]["image"] == f"calliope/{Path(first).name}"
assert prompt["131"]["inputs"]["first_frame"] == ["901", 0]
assert "last_frame" not in prompt["131"]["inputs"]
assert prompt["124"]["inputs"]["denoise"] == 0.8
def test_fl2v_chain_uploads_first_and_last_frame_into_their_own_inputs(client, named_comfy):
with _real_run(client, named_comfy):
wf_id = _import_workflow(client, "H3 首尾帧生视频", "video", _load(I2V))
listed = next(w for w in client.get("/api/workflows").json() if w["id"] == wf_id)
assert listed["usage"] == "image2video"
slots = {i["nodeId"]: i for i in listed["input_schema"] if i["kind"] == "image"}
assert slots["901"]["role"] == "first_frame" and slots[LAST_KEY]["role"] == "last_frame"
last = _upload(client, "尾帧.png")
r = _generate(client, wf_id, {"1": "从白天过渡到夜晚", LAST_KEY: last})
assert r.status_code == 400 and "请上传首帧" in r.json()["detail"]
first = _upload(client, "首帧.png")
r = _generate(client, wf_id, {"1": "从白天过渡到夜晚", "901": first, LAST_KEY: last})
assert r.status_code == 200, r.text
assert _wait_done(client, r.json()["job"]["id"])["status"] == "done"
prompt = named_comfy.prompts[0]["prompt"]
node = prompt["131"]["inputs"]
assert prompt[node["first_frame"][0]]["inputs"]["image"] == f"calliope/{Path(first).name}"
assert prompt[node["last_frame"][0]]["inputs"]["image"] == f"calliope/{Path(last).name}"
assert node["first_frame"][0] != node["last_frame"][0]
assert sorted(named_comfy.upload_names) == sorted([Path(first).name, Path(last).name])
def test_migration_renames_the_imported_first_frame_workflow(tmp_path):
import asyncio
import sqlite3
from calliope.db import migrate_db
db = tmp_path / "c.db"
asyncio.run(migrate_db(db))
conn = sqlite3.connect(db)
i2v, ref = json.dumps(_load(I2V)), json.dumps(_load(REF))
conn.executemany(
"INSERT INTO workflows (name, kind, workflow_json, description) VALUES (?, 'video', ?, ?)",
[
("H3 图生视频·首帧(04 用户提供)", i2v, None),
("H3图生视频·首帧", i2v, "我自己写的说明"),
("图生视频·首帧模板", ref, None), # not the FL2VA graph — left alone
("我的视频工作流", i2v, None), # user already renamed — left alone
],
)
conn.commit()
conn.close()
asyncio.run(migrate_db(db))
asyncio.run(migrate_db(db)) # idempotent
conn = sqlite3.connect(db)
rows = conn.execute("SELECT name, description FROM workflows ORDER BY id").fetchall()
conn.close()
assert rows[0] == (
"H3 首尾帧生视频(04 用户提供)",
"首帧必填,尾帧可选:只传首帧为图生视频,同时传尾帧为首尾帧生视频",
)
assert rows[1] == ("H3首尾帧生视频", "我自己写的说明") # suffix kept, description kept
assert rows[2] == ("图生视频·首帧模板", None)
assert rows[3] == ("我的视频工作流", None)
def test_broken_qwen_import_warns_and_generation_is_refused(client, named_comfy):
with _real_run(client, named_comfy):
analyzed = client.post("/api/workflows/analyze", json={"workflow_json": _load(QWEN_EDIT)})
assert analyzed.status_code == 200
codes = {w["code"] for w in analyzed.json()["warnings"]}
assert {"missing_class_type", "missing_model_link", "orphan_latent"} <= codes
# importing is allowed (the user may fix it later) …
wf_id = _import_workflow(client, "Qwen 图片编辑", "image", _load(QWEN_EDIT))
listed = next(w for w in client.get("/api/workflows").json() if w["id"] == wf_id)
assert any(w["level"] == "error" for w in listed["warnings"])
# … but it never reaches ComfyUI and never shows as done
img = _upload(client, "门面.png")
r = _generate(client, wf_id, {"4@prompt": "换招牌", "10": img}, source="studio-image")
assert r.status_code == 400
assert "重新导出" in r.json()["detail"]
assert client.get("/api/playground/jobs").json() == []
assert named_comfy.prompts == [] and named_comfy.upload_names == []
def test_repaired_qwen_export_runs_with_its_input_image(client, named_comfy):
wf = _load(QWEN_EDIT)
wf["1"] = {
"class_type": "UNETLoader",
"inputs": {
"unet_name": "qwen_image_2.1_int8_convrot.safetensors",
"weight_dtype": "default",
},
}
del wf["9"]
wf["6"]["inputs"]["model"] = ["1", 0]
with _real_run(client, named_comfy):
named_comfy.out_name = "qwen_image_00001_.png"
wf_id = _import_workflow(client, "Qwen 图片编辑(修复版)", "image", wf)
img = _upload(client, "门面.png")
r = _generate(
client, wf_id, {"4@prompt": "换招牌", "10": img, "4@resolution": 1536, "6@steps": 20},
source="studio-image",
)
assert r.status_code == 200, r.text
job = _wait_done(client, r.json()["job"]["id"])
assert job["status"] == "done" and job["output_paths"]
prompt = named_comfy.prompts[0]["prompt"]
assert prompt["10"]["inputs"]["image"] == f"calliope/{Path(img).name}"
assert prompt["4"]["inputs"]["images.image_1"] == ["10", 0]
assert prompt["4"]["inputs"]["resolution"] == 1536
assert prompt["6"]["inputs"]["steps"] == 20
def test_worker_refuses_broken_workflow_from_other_callers(client, named_comfy):
"""Workbench/agent jobs skip the playground check — the worker still stops them."""
from calliope.db import get_db
from calliope.queue.manager import queue_manager
from calliope.routers.playground import ensure_playground_project
with _real_run(client, named_comfy):
wf_id = _import_workflow(client, "Qwen 图片编辑", "image", _load(QWEN_EDIT))
conn = get_db(settings.db_path)
try:
pid = ensure_playground_project(conn)
finally:
conn.close()
job = queue_manager.enqueue(
project_id=pid, kind="image", workflow_id=wf_id, scene_id=None,
payload={"input_values": {"4@prompt": "x"}, "source": "playground"},
)
done = _wait_done(client, job["id"])
assert done["status"] == "failed"
assert "重新导出" in (done["error"] or "")
assert named_comfy.prompts == []
@@ -216,6 +216,18 @@ def test_settings_accepts_agent_llm_assignments(client):
# Unknown role keys are dropped, known roles stored (incl. explicit None)
assert body["agent_llm_assignments"] == {"video": a_id, "story": None}
# HEL-619: updates are per-role incremental — a partial payload must
# not wipe roles it does not mention (rapid-switch race).
r = client.post(
"/api/settings",
json={"agent_llm_assignments": {"main": a_id}},
)
assert r.status_code == 200
body = r.json()
assert body["agent_llm_assignments"]["main"] == a_id
assert body["agent_llm_assignments"]["video"] == a_id
assert body["agent_llm_assignments"]["story"] is None
r = client.post(
"/api/settings",
json={"agent_llm_assignments": {"video": "no-such-profile"}},
+266
View File
@@ -0,0 +1,266 @@
"""媒体组泛化(HEL-643 批次 3):参考视频/参考音频按 JSON 实际连线进入
input_schema 与提交流程;无连线则无入口;05_ref2va 只保留参考图。
视频/音频参考的真实 H3 JSON 暂无可执行样本(.15 无 ComfyUI),本套用
自造 fixture + fake ComfyUI 验证解析/表单/回填/上传全链路(模拟链路)。
"""
from __future__ import annotations
import json
import time
from pathlib import Path
import pytest
from calliope.comfyui.parser import parse_dynamic_inputs
from calliope.comfyui.patcher import patch_workflow
from calliope.comfyui.validate import validate_input_values
from calliope.config import settings
from tests.test_studio_generation import PNG_1PX, _real_run
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
@pytest.fixture
def uploads_dir(client):
d = settings.assets_dir / "uploads"
d.mkdir(parents=True, exist_ok=True)
return d
def _load_05() -> dict:
return json.loads((FIXTURES / "h3_ref2va_05_API.json").read_text(encoding="utf-8"))
def _ref2va_workflow(*, images=1, videos=1, audios=1) -> dict:
"""H3 Ref2VA 形状的自造 JSON:图/视频/音频各一组 Autogrow 连线。"""
wf: dict = {
"1": {
"class_type": "PrimitiveStringMultiline",
"inputs": {"value": "提示词"},
},
"92": {
"class_type": "SaveVideo",
"inputs": {"video": ["131", 0]},
},
"131": {
"class_type": "MiniMaxH3ReferenceToVideo",
"inputs": {"prompt": ["1", 0]},
},
"901": {"class_type": "LoadImage", "inputs": {"image": "ref.png"}},
"902": {"class_type": "LoadVideo", "inputs": {"file": "ref.mp4", "force_rate": 0}},
"903": {"class_type": "LoadAudio", "inputs": {"audio": "ref.wav"}},
}
ins = wf["131"]["inputs"]
for i in range(images):
ins[f"ref_images.ref_image_{i}"] = ["901", 0]
for i in range(videos):
ins[f"ref_videos.ref_video_{i}"] = ["902", 0]
for i in range(audios):
ins[f"ref_audios.ref_audio_{i}"] = ["903", 0]
return wf
def _entry(schema: list[dict], key_suffix: str) -> dict | None:
for e in schema:
if e["nodeId"].endswith("@" + key_suffix):
return e
return None
# ---------------------------------------------------------------------------
# Schema: 入口只由实际连线决定
# ---------------------------------------------------------------------------
def test_ref2va_schema_exposes_three_media_groups():
schema = parse_dynamic_inputs(_ref2va_workflow())
img = _entry(schema, "ref_images")
vid = _entry(schema, "ref_videos")
aud = _entry(schema, "ref_audios")
assert img and img["kind"] == "image_list" and img["role"] == "reference_image"
assert img["max"] == 9 and img["label"] == "参考图"
assert vid and vid["kind"] == "video_list" and vid["role"] == "reference_video"
assert vid["max"] == 3 and vid["label"] == "参考视频"
assert aud and aud["kind"] == "audio_list" and aud["role"] == "reference_audio"
assert aud["max"] == 3 and aud["label"] == "参考音频"
# 官方限额提示按节点类名驱动(通用检测不携带 hint)
assert img["media_hint"] == vid["media_hint"] == aud["media_hint"] == "minimax_h3_ref2va"
# 被组吸收的 loader 不再单独出现
assert not any(e["nodeId"] == "902" for e in schema)
assert not any(e["nodeId"] == "903" for e in schema)
def test_unwired_media_kinds_produce_no_entry():
schema = parse_dynamic_inputs(_ref2va_workflow(videos=0, audios=0))
assert _entry(schema, "ref_images") is not None
assert _entry(schema, "ref_videos") is None
assert _entry(schema, "ref_audios") is None
def test_generic_class_gets_group_without_hint():
wf = _ref2va_workflow(videos=0, audios=0)
wf["20"] = {"class_type": "GenericRefNode", "inputs": {}}
wf["701"] = {"class_type": "VHS_LoadVideo", "inputs": {"video": "a.mp4"}}
wf["702"] = {"class_type": "VHS_LoadVideo", "inputs": {"video": "b.mp4"}}
wf["20"]["inputs"]["media.clip_0"] = ["701", 0]
wf["20"]["inputs"]["media.clip_1"] = ["702", 0]
schema = parse_dynamic_inputs(wf)
g = _entry(schema, "media")
assert g and g["kind"] == "video_list" and g["max"] == 9
assert "media_hint" not in g
def test_05_ref2va_fixture_stays_image_only():
schema = parse_dynamic_inputs(_load_05())
lists = [e for e in schema if e["kind"].endswith("_list")]
assert len(lists) == 1
assert lists[0]["kind"] == "image_list" and lists[0]["max"] == 9
assert lists[0]["nodeId"] == "131@ref_images"
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
def _media_file(path: str, data: bytes) -> str:
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_bytes(data)
return str(p)
def test_validate_video_and_audio_lists(uploads_dir):
schema = parse_dynamic_inputs(_ref2va_workflow())
img_key = _entry(schema, "ref_images")["nodeId"]
vid_key = _entry(schema, "ref_videos")["nodeId"]
aud_key = _entry(schema, "ref_audios")["nodeId"]
png = _media_file(str(uploads_dir / "ok.png"), PNG_1PX)
mp4 = _media_file(str(uploads_dir / "ok.mp4"), b"00")
mp3 = _media_file(str(uploads_dir / "ok.mp3"), b"00")
clean, errs = validate_input_values(
schema, {"1": "p", img_key: [png], vid_key: [mp4], aud_key: [mp3]}
)
assert errs == [] and clean[vid_key] == [mp4] and clean[aud_key] == [mp3]
# 错误格式(图片文件填视频槽 / 视频文件填音频槽)
_, errs = validate_input_values(
schema, {"1": "p", img_key: [png], vid_key: [png], aud_key: [mp3]}
)
assert errs == ["参考视频 1:格式不支持,请使用 MP4 / WebM / MOV"]
_, errs = validate_input_values(
schema, {"1": "p", img_key: [png], vid_key: [mp4], aud_key: [mp4]}
)
assert errs == ["参考音频 1:格式不支持,请使用 MP3 / WAV / M4A"]
# 上限(H3 官方 3 段)与最少 1 段
four = [_media_file(str(uploads_dir / f"v{i}.mp4"), b"00") for i in range(4)]
_, errs = validate_input_values(
schema, {"1": "p", img_key: [png], vid_key: four, aud_key: [mp3]}
)
assert errs == ["参考视频最多 3 段(当前 4 段),请删除多余的视频"]
_, errs = validate_input_values(
schema, {"1": "p", img_key: [png], aud_key: [mp3]}
)
assert errs == ["需要至少 1 段参考视频"]
# ---------------------------------------------------------------------------
# Patcher + full chain against fake ComfyUI
# ---------------------------------------------------------------------------
def test_patcher_clones_and_backfills_each_media_kind(uploads_dir):
wf = _ref2va_workflow()
schema = parse_dynamic_inputs(wf)
pngs = [_media_file(str(uploads_dir / "r1.png"), PNG_1PX),
_media_file(str(uploads_dir / "r2.png"), PNG_1PX)]
mp4 = _media_file(str(uploads_dir / "v1.mp4"), b"00")
mp3 = _media_file(str(uploads_dir / "a1.mp3"), b"00")
patched = patch_workflow(
wf,
{
_entry(schema, "ref_images")["nodeId"]: pngs,
_entry(schema, "ref_videos")["nodeId"]: [mp4],
_entry(schema, "ref_audios")["nodeId"]: [mp3],
},
)
ins = patched["131"]["inputs"]
image_links = [v for k, v in ins.items() if k.startswith("ref_images.")]
video_links = [v for k, v in ins.items() if k.startswith("ref_videos.")]
audio_links = [v for k, v in ins.items() if k.startswith("ref_audios.")]
assert len(image_links) == 2 and len(video_links) == 1 and len(audio_links) == 1
loaders = {nid: n for nid, n in patched.items()
if n.get("class_type") in ("LoadImage", "LoadVideo", "LoadAudio")}
imgs = [n for n in loaders.values() if n["class_type"] == "LoadImage"]
vids = [n for n in loaders.values() if n["class_type"] == "LoadVideo"]
auds = [n for n in loaders.values() if n["class_type"] == "LoadAudio"]
assert len(imgs) == 2 and all(n["inputs"]["image"] in pngs for n in imgs)
assert vids and vids[0]["inputs"]["file"] == mp4
assert auds and auds[0]["inputs"]["audio"] == mp3
# 克隆出的节点带中文槽位标题
assert {n["_meta"]["title"] for n in imgs} == {"参考图 1", "参考图 2"}
assert vids[0]["_meta"]["title"] == "参考视频 1"
assert auds[0]["_meta"]["title"] == "参考音频 1"
def test_full_chain_uploads_and_backfills_media_groups(client, fake_comfy, uploads_dir):
"""提交 → 上传 → 回填:fake ComfyUI 收到的 prompt 里每类 loader 都
指向已上传文件(模拟链路:fake 端点,非真机 H3)。"""
wf = _ref2va_workflow()
png = _media_file(str(uploads_dir / "ref.png"), PNG_1PX)
mp4 = _media_file(str(uploads_dir / "ref.mp4"), b"00")
mp3 = _media_file(str(uploads_dir / "ref.mp3"), b"00")
r = client.post(
"/api/workflows",
json={"name": "媒体组链路", "workflow_json": wf, "usage": "ref2video"},
)
assert r.status_code == 200, r.text
wf_id = r.json()["id"]
schema = r.json()["input_schema"]
with _real_run(client, fake_comfy):
fake_comfy.out_name = "media_out.mp4"
r = client.post(
f"/api/workflows/{wf_id}/test",
json={
"input_values": {
_entry(schema, "ref_images")["nodeId"]: [png],
_entry(schema, "ref_videos")["nodeId"]: [mp4],
_entry(schema, "ref_audios")["nodeId"]: [mp3],
}
},
)
assert r.status_code == 200, r.text
job_id = r.json()["job"]["id"]
deadline = time.time() + 25
job = {}
while time.time() < deadline:
job = client.get(f"/api/jobs/{job_id}").json()
if job["status"] not in ("pending", "running"):
break
time.sleep(0.4)
assert job["status"] == "done", job.get("error")
assert len(fake_comfy.uploads) >= 3 # 图、视频、音频各一次
queued = fake_comfy.prompts[-1]["prompt"]
loaders = [n for n in queued.values()
if n.get("class_type") in ("LoadImage", "LoadVideo", "LoadAudio")]
by_field = {
(n["class_type"], k): v
for n in loaders
for k, v in n["inputs"].items()
if isinstance(v, str) and "uploaded-input" in v
}
assert ("LoadImage", "image") in by_field
assert ("LoadVideo", "file") in by_field
assert ("LoadAudio", "audio") in by_field
# 组连线数与提交数一致
ins = queued["131"]["inputs"]
assert len([k for k in ins if k.startswith("ref_images.")]) == 1
assert len([k for k in ins if k.startswith("ref_videos.")]) == 1
assert len([k for k in ins if k.startswith("ref_audios.")]) == 1
+2 -1
View File
@@ -114,7 +114,8 @@ def test_queue_prompt_surfaces_comfy_error_body(monkeypatch):
msg = asyncio.run(run())
assert "prompt_outputs_failed_validation" in msg
assert "node 12" in msg
assert "节点 12" in msg
assert "HTTP 400" in msg
assert "Invalid audio file" in msg
@@ -0,0 +1,350 @@
"""HEL-684 — playground takes flow back to their workbench source row.
Covers the acceptance list: real-context-only passthrough (no guessing,
stale rows rejected), explicit flow-back with idempotency (repeated
operations create no duplicates), cancelled/independent paths never
writing the project, and playground job deletion clearing the link.
"""
from __future__ import annotations
import tempfile
import time
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from calliope.db import migrate_db
from calliope.main import create_app
SAMPLE_WORKFLOW = {
"1": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "hello"},
"_meta": {"title": "Prompt (Input:prompt)"},
},
"2": {
"class_type": "SaveImage",
"inputs": {"images": ["1", 0]},
"_meta": {"title": "Save (Output:image)"},
},
}
SAMPLE_VIDEO_WORKFLOW = {
"1": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "hello"},
"_meta": {"title": "Prompt (Input:prompt)"},
},
"2": {
"class_type": "SaveVideo",
"inputs": {"video": ["1", 0]},
"_meta": {"title": "Save (Output:video)"},
},
}
@pytest.fixture
def client(monkeypatch):
with tempfile.TemporaryDirectory() as tmpdir:
import asyncio
import calliope.config as config_module
s = config_module.settings
prev = {
"data_dir": s.data_dir,
"assets_dir": s.assets_dir,
"dry_run": s.dry_run,
}
s.data_dir = Path(tmpdir)
s.assets_dir = Path(tmpdir) / "assets"
s.assets_dir.mkdir(parents=True, exist_ok=True)
s.dry_run = True
monkeypatch.setattr(config_module.Settings, "save_config_file", lambda self: None)
asyncio.run(migrate_db(s.db_path))
app = create_app()
try:
with TestClient(app) as c:
yield c
finally:
for k, v in prev.items():
setattr(s, k, v)
def _wait_done(client: TestClient, job_id: int) -> dict:
for _ in range(60):
jobs = client.get("/api/playground/jobs").json()
done = next((j for j in jobs if j["id"] == job_id and j["status"] == "done"), None)
if done and done.get("output_paths"):
return done
time.sleep(0.25)
raise AssertionError("playground job did not finish in dry-run")
def _seed_assets_stage(client: TestClient) -> dict:
proj = client.post("/api/projects", json={"title": "Reel"}).json()
# Real stage rows the way the workbench itself creates them.
patch = client.patch(
f"/api/workbench/projects/{proj['id']}/stages/assets",
json={
"content": {
"assets": [
{
"id": "asset-1",
"kind": "character",
"name": "林夏",
"description": "",
"prompt": "a stoic courier",
"negative_prompt": "",
}
]
}
},
)
assert patch.status_code == 200, patch.text
return proj
def _context(proj: dict, **over: object) -> dict:
base = {
"project_id": proj["id"],
"stage": "assets",
"source_kind": "asset",
"source_id": "asset-1",
"label": "角色「林夏」",
}
base.update(over)
return base
def _make_workflow(client: TestClient, name: str, workflow: dict) -> int:
r = client.post(
"/api/workflows",
json={
"name": name,
"kind": "video" if "SaveVideo" in str(workflow) else "image",
"workflow_json": workflow,
},
)
assert r.status_code == 200, r.text
return r.json()["id"]
# ── context passthrough ────────────────────────────────────────────────
def test_generate_with_context_stamps_job_and_stays_on_playground(client):
wf_id = _make_workflow(client, "FB Img", SAMPLE_WORKFLOW)
proj = _seed_assets_stage(client)
r = client.post(
"/api/playground/generate",
json={
"workflow_id": wf_id,
"input_values": {"1": "a cat"},
"source": "studio-image",
"context": _context(proj),
},
)
assert r.status_code == 200, r.text
job = r.json()["job"]
assert job["payload"]["workbench"]["project_id"] == proj["id"]
assert job["payload"]["workbench"]["source_id"] == "asset-1"
assert job["payload"]["workbench"]["label"] == "角色「林夏」"
# The job itself rides the hidden playground project — independent
# desk behavior (feed, SSE, tasks) is untouched.
scratch = client.get("/api/playground/project").json()
assert job["project_id"] == scratch["id"]
def test_generate_without_context_writes_nothing_to_any_project(client):
wf_id = _make_workflow(client, "FB Solo", SAMPLE_WORKFLOW)
_seed_assets_stage(client)
r = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "input_values": {"1": "solo"}},
)
assert r.status_code == 200
assert "workbench" not in r.json()["job"]["payload"]
def test_generate_rejects_fake_or_stale_context(client):
wf_id = _make_workflow(client, "FB Fake", SAMPLE_WORKFLOW)
proj = _seed_assets_stage(client)
# Unknown project
r = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "context": _context(proj, project_id=99999)},
)
assert r.status_code == 404
# Source row that does not exist in the stage content
r = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "context": _context(proj, source_id="asset-404")},
)
assert r.status_code == 409
# Kind mismatch: assets stage wants an image workflow
video_wf = _make_workflow(client, "FB Vid", SAMPLE_VIDEO_WORKFLOW)
r = client.post(
"/api/playground/generate",
json={"workflow_id": video_wf, "context": _context(proj)},
)
assert r.status_code == 422
# The playground scratch project is not a valid source
scratch = client.get("/api/playground/project").json()
r = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "context": _context(proj, project_id=scratch["id"])},
)
assert r.status_code == 400
# ── flow back ──────────────────────────────────────────────────────────
def test_attach_workbench_links_take_to_source_row(client):
wf_id = _make_workflow(client, "FB Link", SAMPLE_WORKFLOW)
proj = _seed_assets_stage(client)
job = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "input_values": {"1": "a cat"}, "context": _context(proj)},
).json()["job"]
# Not done yet → refused, nothing written.
early = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
assert early.status_code == 400
done = _wait_done(client, job["id"])
r = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
assert r.status_code == 200, r.text
body = r.json()
assert body["already_attached"] is False
assert body["project_id"] == proj["id"]
assert body["target_id"] == "asset-1"
# The workbench overview now shows the artifact on the source row,
# through the exact enrichment path in-flow generation uses.
overview = client.get(f"/api/workbench/projects/{proj['id']}").json()
assets_stage = next(s for s in overview["stages"] if s["stage_key"] == "assets")
row = next(a for a in assets_stage["content"]["assets"] if a["id"] == "asset-1")
assert row["job"]["id"] == body["attached_job_id"]
assert row["job"]["status"] == "done"
assert row["job"]["output"] == done["output_paths"][0]
# Repeated flow-back: idempotent, no duplicate row.
again = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
assert again.status_code == 200
assert again.json()["already_attached"] is True
assert again.json()["attached_job_id"] == body["attached_job_id"]
# The desk jobs list reports the honest 已回流 marker.
jobs = client.get("/api/playground/jobs").json()
listed = next(j for j in jobs if j["id"] == job["id"])
assert listed["workbench_attach"]["job_id"] == body["attached_job_id"]
def test_attach_workbench_requires_real_context(client):
wf_id = _make_workflow(client, "FB NoCtx", SAMPLE_WORKFLOW)
job = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "input_values": {"1": "solo"}},
).json()["job"]
_wait_done(client, job["id"])
r = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
assert r.status_code == 400
assert "独立" in r.json()["detail"]
def test_attach_workbench_refuses_regenerated_source(client):
wf_id = _make_workflow(client, "FB Stale", SAMPLE_WORKFLOW)
proj = _seed_assets_stage(client)
job = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "input_values": {"1": "a cat"}, "context": _context(proj)},
).json()["job"]
_wait_done(client, job["id"])
# Stage content regenerated: the old row id is gone.
client.patch(
f"/api/workbench/projects/{proj['id']}/stages/assets",
json={
"content": {
"assets": [
{
"id": "asset-2",
"kind": "prop",
"name": "Blaster",
"description": "",
"prompt": "a blaster",
"negative_prompt": "",
}
]
}
},
)
r = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
assert r.status_code == 409
overview = client.get(f"/api/workbench/projects/{proj['id']}").json()
assets_stage = next(s for s in overview["stages"] if s["stage_key"] == "assets")
assert all("job" not in a for a in assets_stage["content"]["assets"])
def test_attach_workbench_allows_new_flow_job_to_take_over(client):
"""A later in-flow generation for the same row wins the display, as
the enrichment keeps the newest workbench job per target."""
wf_id = _make_workflow(client, "FB Takeover", SAMPLE_WORKFLOW)
proj = _seed_assets_stage(client)
job = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "input_values": {"1": "a cat"}, "context": _context(proj)},
).json()["job"]
_wait_done(client, job["id"])
link = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]}).json()
flow = client.post(
f"/api/workbench/projects/{proj['id']}/generate",
json={
"kind": "image",
"stage_key": "assets",
"target_id": "asset-1",
"prompt": "in-flow take",
},
)
assert flow.status_code == 200, flow.text
overview = client.get(f"/api/workbench/projects/{proj['id']}").json()
assets_stage = next(s for s in overview["stages"] if s["stage_key"] == "assets")
row = next(a for a in assets_stage["content"]["assets"] if a["id"] == "asset-1")
assert row["job"]["id"] != link["attached_job_id"]
def test_delete_playground_job_removes_workbench_link(client):
wf_id = _make_workflow(client, "FB Del", SAMPLE_WORKFLOW)
proj = _seed_assets_stage(client)
job = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "input_values": {"1": "a cat"}, "context": _context(proj)},
).json()["job"]
done = _wait_done(client, job["id"])
link = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]}).json()
dead = client.delete(f"/api/playground/jobs/{job['id']}")
assert dead.status_code == 200
assert dead.json()["removed_workbench_links"] == [link["attached_job_id"]]
# The link row is gone: the project row no longer points at deleted files.
overview = client.get(f"/api/workbench/projects/{proj['id']}").json()
assets_stage = next(s for s in overview["stages"] if s["stage_key"] == "assets")
row = next(a for a in assets_stage["content"]["assets"] if a["id"] == "asset-1")
assert "job" not in row or row["job"]["id"] != link["attached_job_id"]
assert not Path(done["output_paths"][0]).exists()
+4
View File
@@ -89,6 +89,10 @@ def test_list_projects_includes_stats(client):
"character_count": 0,
"asset_ready_count": 0,
"asset_total_count": 0,
# Five-stage workbench progress (HEL-614): fresh project = not started.
"stage_confirmed": 0,
"stage_status": {},
"active_jobs": 0,
}
@@ -0,0 +1,411 @@
"""HEL-627: the two workflows the user runs (Qwen Image 2.1 文生图 and
MiniMax H3 reference 文生视频) expose every widget they really have, the
values land in the /prompt payload, and a job is only "done" once ComfyUI
returned real files — empty, failed, lost and placeholder runs never are."""
from __future__ import annotations
import json
import threading
import time
from http.server import ThreadingHTTPServer
from pathlib import Path
import pytest
from calliope.comfyui.parser import (
RESOLUTION_ASPECT_RATIOS,
effective_input_schema,
parse_dynamic_inputs,
resolution_selector_size,
)
from calliope.comfyui.validate import validate_input_values
from calliope.config import settings
from calliope.db import get_db
from tests.test_studio_generation import (
_FakeComfyHandler,
_import_workflow,
_real_run,
)
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
T2I = "wen_sheng_tu_API.json" # image_qwen_image_2_1_t2i (2).json
T2V = "wen_sheng_shipin_API.json" # 01_reference_4step_sla (1).json
def _load(name: str) -> dict:
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
def _by_role(inputs: list[dict]) -> dict[str, dict]:
return {i["role"]: i for i in inputs if i.get("role")}
class _StatusHandler(_FakeComfyHandler):
"""Fake ComfyUI with /queue and scriptable history outcomes."""
def do_GET(self):
srv = self.server
if self.path == "/queue":
if srv.queue_mode is None:
self._json(404, {"error": "not found"})
return
self.server.requests.append(self.path)
items = [[i, pid, {}, {}, []] for i, pid in enumerate(srv.history)]
if srv.queue_mode == "running":
self._json(200, {"queue_running": items, "queue_pending": []})
elif srv.queue_mode == "pending":
blocker = [[-1, "someone-else", {}, {}, []]]
self._json(200, {"queue_running": blocker, "queue_pending": items})
else:
self._json(200, {"queue_running": [], "queue_pending": []})
return
if self.path.startswith("/history/"):
if srv.lose_history or srv.hidden_history_polls > 0:
srv.hidden_history_polls = max(0, srv.hidden_history_polls - 1)
self.server.requests.append(self.path)
self._json(200, {})
return
super().do_GET()
def do_POST(self):
super().do_POST()
if self.path == "/prompt" and self.server.history_patch:
for entry in self.server.history.values():
entry.update(self.server.history_patch)
@pytest.fixture
def status_comfy():
server = ThreadingHTTPServer(("127.0.0.1", 0), _StatusHandler)
server.requests = []
server.prompts = []
server.uploads = []
server.history = {}
server.fail_prompt = False
server.out_name = "out-1.png"
server.outputs_key = "images"
server.queue_mode = None
server.lose_history = False
server.hidden_history_polls = 0
server.history_patch = None
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server
finally:
server.shutdown()
server.server_close()
def _wait_final(client, job_id: int, timeout: float = 25.0) -> dict:
deadline = time.time() + timeout
while time.time() < deadline:
for j in client.get("/api/playground/jobs").json():
if j["id"] == job_id and j["status"] in ("done", "failed"):
return j
time.sleep(0.2)
raise AssertionError(f"job {job_id} did not finish")
def _generate(client, wf_id: int, values: dict, source: str = "studio-image"):
return client.post(
"/api/playground/generate",
json={
"workflow_id": wf_id,
"input_values": values,
"source": source,
"endpoint_id": settings.comfyui_active_id,
},
)
# ---------------------------------------------------------------------------
# Parameter detection on the two real workflows
# ---------------------------------------------------------------------------
def test_real_t2i_exposes_ratio_megapixels_steps_denoise():
roles = _by_role(parse_dynamic_inputs(_load(T2I)))
assert set(roles) == {
"prompt", "negative", "aspect_ratio", "megapixels", "batch",
"seed", "steps", "cfg", "denoise",
}
ratio = roles["aspect_ratio"]
assert ratio["nodeId"] == "13@aspect_ratio"
assert ratio["kind"] == "select"
assert ratio["defaultValue"] == "1:1 (Square)"
assert ratio["options"] == list(RESOLUTION_ASPECT_RATIOS)
assert ratio["resolution"] == {"megapixels": "13@megapixels", "multiple": 8}
assert roles["megapixels"]["defaultValue"] == 1
assert roles["steps"]["nodeId"] == "459:458@steps"
assert roles["steps"]["defaultValue"] == 25 and roles["steps"]["integer"] is True
assert roles["denoise"]["nodeId"] == "459:458@denoise"
assert (roles["denoise"]["min"], roles["denoise"]["max"]) == (0.0, 1.0)
# Width/height are wired from ResolutionSelector; they are not faked as
# free inputs, and the encoder's reference-image `resolution` is not exposed.
assert "width" not in roles and "height" not in roles
assert all("resolution" not in i["nodeId"] for i in parse_dynamic_inputs(_load(T2I)))
def test_real_t2v_exposes_ratio_steps_denoise_seed_duration():
inputs = parse_dynamic_inputs(_load(T2V))
roles = _by_role(inputs)
assert set(roles) == {
"prompt", "aspect_ratio", "megapixels", "steps", "denoise", "seed", "duration",
}
assert roles["aspect_ratio"]["defaultValue"] == "16:9 (Widescreen)"
assert roles["aspect_ratio"]["resolution"]["multiple"] == 32
assert roles["megapixels"]["defaultValue"] == 0.7
assert roles["steps"]["nodeId"] == "124@steps" and roles["steps"]["defaultValue"] == 4
assert roles["seed"]["nodeId"] == "129@noise_seed"
assert roles["duration"]["nodeId"] == "133" and roles["duration"]["defaultValue"] == 10
# BasicGuider has no CFG; the frame expression hard-codes 24 fps, so no fps field.
assert "cfg" not in roles and "fps" not in roles
def test_resolution_matches_comfyui_resolution_selector():
# The H3 workflow's own title documents 1152x640 for 16:9 · 0.7 MP · ×32.
assert resolution_selector_size("16:9 (Widescreen)", 0.7, 32) == (1152, 640)
assert resolution_selector_size("1:1 (Square)", 1, 8) == (1024, 1024)
assert resolution_selector_size("9:16 (Portrait Widescreen)", 1, 8) == (768, 1368)
def test_validation_rejects_bad_values_in_chinese():
schema = parse_dynamic_inputs(_load(T2I))
clean, errors = validate_input_values(
schema,
{
"459:452@prompt": "猫",
"13@aspect_ratio": "5:4",
"459:458@steps": 12.5,
"459:458@denoise": 1.5,
"13@megapixels": "abc",
},
)
assert any("画面比例" in e and "5:4" in e for e in errors)
assert any("采样步数" in e and "整数" in e for e in errors)
assert any("降噪强度" in e and "不能大于 1" in e for e in errors)
assert any("总像素" in e and "数字" in e for e in errors)
clean, errors = validate_input_values(schema, {"459:458@steps": "30", "13@megapixels": 2})
assert errors == [] # an omitted prompt falls back to the workflow's own text
assert clean["459:458@steps"] == 30 and isinstance(clean["459:458@steps"], int)
blank = [dict(e, defaultValue="") if e.get("role") == "prompt" else e for e in schema]
_, errors = validate_input_values(blank, {"459:452@prompt": " "})
assert errors == ["请填写「正向提示词」"]
def test_stale_auto_schema_is_refreshed_without_reimport(client):
wf = _load(T2I)
stale = [
{"nodeId": "459:452@prompt", "label": "正向提示词", "role": "prompt",
"kind": "textarea", "defaultValue": "x", "required": True, "auto": True},
]
conn = get_db(settings.db_path)
try:
cur = conn.execute(
"INSERT INTO workflows"
" (name, kind, workflow_json, input_schema, output_schema, is_enabled)"
" VALUES (?, 'image', ?, ?, '[]', 1)",
("旧导入·Qwen2.1", json.dumps(wf), json.dumps(stale)),
)
conn.commit()
wf_id = cur.lastrowid
finally:
conn.close()
roles = _by_role(client.get(f"/api/workflows/{wf_id}").json()["input_schema"])
assert {"aspect_ratio", "megapixels", "steps", "denoise"} <= set(roles)
def test_tagged_schema_is_kept_as_stored():
stored = [{"nodeId": "6", "label": "P", "role": "prompt", "kind": "textarea",
"required": True}]
assert effective_input_schema(stored, _load(T2I)) == stored
# ---------------------------------------------------------------------------
# Submit payload + job status against a fake ComfyUI
# ---------------------------------------------------------------------------
def test_t2i_values_reach_the_prompt_payload(client, status_comfy):
with _real_run(client, status_comfy):
wf_id = _import_workflow(client, "真实·Qwen2.1(参数)", "image", _load(T2I))
r = _generate(
client,
wf_id,
{
"459:452@prompt": "雨夜霓虹街道",
"459:452@negative_prompt": "模糊",
"13@aspect_ratio": "16:9 (Widescreen)",
"13@megapixels": 2,
"459:458@steps": "30",
"459:458@cfg": 2.5,
"459:458@denoise": 0.8,
"459:458@seed": 7,
},
)
assert r.status_code == 200, r.text
job = _wait_final(client, r.json()["job"]["id"])
assert job["status"] == "done", job.get("error")
queued = status_comfy.prompts[-1]["prompt"]
assert queued["13"]["inputs"]["aspect_ratio"] == "16:9 (Widescreen)"
assert queued["13"]["inputs"]["megapixels"] == 2
assert queued["459:458"]["inputs"]["steps"] == 30
assert queued["459:458"]["inputs"]["cfg"] == 2.5
assert queued["459:458"]["inputs"]["denoise"] == 0.8
assert queued["459:458"]["inputs"]["seed"] == 7
assert queued["459:452"]["inputs"]["prompt"] == "雨夜霓虹街道"
# Wiring intact: the latent still takes its size from the selector.
assert queued["459:456"]["inputs"]["width"] == ["13", 0]
assert queued["459:456"]["inputs"]["height"] == ["13", 1]
assert job["comfy_prompt_id"] in status_comfy.history
assert job["phase"] == "finished"
assert job["output_paths"] and Path(job["output_paths"][0]).stat().st_size > 0
def test_t2v_values_reach_the_prompt_payload(client, status_comfy):
status_comfy.outputs_key = "images" # SaveVideo reports under "images" + animated
status_comfy.out_name = "clip.mp4"
with _real_run(client, status_comfy):
wf_id = _import_workflow(client, "真实·H3 参考生视频", "video", _load(T2V))
r = _generate(
client,
wf_id,
{
"1": "地铁车厢里的长镜头",
"115@aspect_ratio": "9:16 (Portrait Widescreen)",
"115@megapixels": 0.5,
"124@steps": 6,
"124@denoise": 1,
"129@noise_seed": 1234,
"133": 5,
},
source="studio-video",
)
assert r.status_code == 200, r.text
job = _wait_final(client, r.json()["job"]["id"])
assert job["status"] == "done", job.get("error")
queued = status_comfy.prompts[-1]["prompt"]
assert queued["1"]["inputs"]["value"] == "地铁车厢里的长镜头"
assert queued["115"]["inputs"]["aspect_ratio"] == "9:16 (Portrait Widescreen)"
assert queued["115"]["inputs"]["megapixels"] == 0.5
assert queued["124"]["inputs"]["steps"] == 6
assert queued["129"]["inputs"]["noise_seed"] == 1234
assert queued["133"]["inputs"]["value"] == 5
assert queued["132"]["inputs"]["values.a"] == ["133", 0]
def test_invalid_values_are_rejected_before_queueing(client, status_comfy):
with _real_run(client, status_comfy):
wf_id = _import_workflow(client, "真实·Qwen2.1(校验)", "image", _load(T2I))
r = _generate(client, wf_id, {"459:452@prompt": "猫", "459:458@steps": 0})
assert r.status_code == 400
assert "采样步数" in r.json()["detail"] and "不能小于 1" in r.json()["detail"]
r = _generate(client, wf_id, {"459:452@prompt": "猫", "13@megapixels": 99})
assert r.status_code == 400 and "总像素" in r.json()["detail"]
assert "/prompt" not in status_comfy.requests
def test_empty_outputs_never_count_as_done(client, status_comfy):
status_comfy.history_patch = {"outputs": {}}
with _real_run(client, status_comfy):
wf_id = _import_workflow(client, "真实·Qwen2.1(空结果)", "image", _load(T2I))
job = _wait_final(client, _generate(client, wf_id, {"459:452@prompt": "猫"}).json()["job"]["id"])
assert job["status"] == "failed"
assert "没有返回任何图片或视频" in job["error"]
assert job["comfy_prompt_id"] and job["comfy_prompt_id"] in job["error"]
assert job["output_paths"] == []
def test_comfy_execution_error_is_failed_with_reason(client, status_comfy):
status_comfy.history_patch = {
"status": {
"status_str": "error",
"completed": False,
"messages": [
["execution_start", {}],
["execution_error", {"node_type": "UNETLoader",
"exception_message": "模型文件不存在"}],
],
},
"outputs": {},
}
with _real_run(client, status_comfy):
wf_id = _import_workflow(client, "真实·Qwen2.1(执行错)", "image", _load(T2I))
job = _wait_final(client, _generate(client, wf_id, {"459:452@prompt": "猫"}).json()["job"]["id"])
assert job["status"] == "failed"
assert "ComfyUI 执行出错" in job["error"] and "UNETLoader" in job["error"]
assert "模型文件不存在" in job["error"]
def test_prompt_lost_from_queue_and_history_fails(client, status_comfy):
status_comfy.lose_history = True
status_comfy.queue_mode = "missing"
with _real_run(client, status_comfy):
wf_id = _import_workflow(client, "真实·Qwen2.1(丢失)", "image", _load(T2I))
job = _wait_final(client, _generate(client, wf_id, {"459:452@prompt": "猫"}).json()["job"]["id"])
assert job["status"] == "failed"
assert "队列和历史里都找不到该任务" in job["error"]
def test_running_then_finished_reports_real_phase(client, status_comfy):
status_comfy.queue_mode = "running"
status_comfy.hidden_history_polls = 3
with _real_run(client, status_comfy):
wf_id = _import_workflow(client, "真实·Qwen2.1(执行中)", "image", _load(T2I))
job_id = _generate(client, wf_id, {"459:452@prompt": "猫"}).json()["job"]["id"]
seen: set[str] = set()
deadline = time.time() + 20
while time.time() < deadline:
row = next(j for j in client.get("/api/playground/jobs").json() if j["id"] == job_id)
if row.get("phase"):
seen.add(row["phase"])
if row["status"] in ("done", "failed"):
break
time.sleep(0.1)
assert row["status"] == "done", row.get("error")
assert "comfy_running" in seen
assert "/queue" in status_comfy.requests
def test_missing_model_rejection_names_the_file():
"""Body captured from the user's ComfyUI 0.37.4 when Qwen 2.1 models are absent."""
import httpx
from calliope.comfyui.client import _surface_error
body = {
"error": {"type": "prompt_outputs_failed_validation"},
"node_errors": {
"459:451": {
"errors": [{
"type": "value_not_in_list",
"message": "Value not in list",
"details": "unet_name: 'qwen_image_2.1_int8_convrot.safetensors' not in [...]",
"extra_info": {
"input_name": "unet_name",
"received_value": "qwen_image_2.1_int8_convrot.safetensors",
},
}],
"class_type": "UNETLoader",
}
},
}
msg = str(_surface_error("ComfyUI 拒绝执行该工作流", httpx.Response(400, json=body)))
assert "节点 459:451(UNETLoader)" in msg
assert "qwen_image_2.1_int8_convrot.safetensors" in msg and "模型文件未安装" in msg
def test_dry_run_result_is_marked_placeholder(client):
wf_id = _import_workflow(client, "真实·Qwen2.1(dry)", "image", _load(T2I))
prev = settings.dry_run
settings.dry_run = True
try:
job = _wait_final(client, _generate(client, wf_id, {"459:452@prompt": "猫"}).json()["job"]["id"])
finally:
settings.dry_run = prev
assert job["status"] == "done"
assert job["phase"] == "dry_run"
assert job["comfy_prompt_id"] is None
+183
View File
@@ -0,0 +1,183 @@
"""B4 draft persistence, concurrency, scope, confirmation and migration regression."""
from __future__ import annotations
import asyncio
import json
import sqlite3
from calliope.agent.deliverables import complete_task
from calliope.agent.harness.runner import runner
from calliope.config import settings
from calliope.db import get_db, migrate_db
TEXT = (
"第一场,清晨海岸,女孩将纸船推向海面,等待父亲归来。"
"第二场,纸船带回树叶,女孩沿着灯塔的光继续前行。"
)
def setup(client, stage="script"):
project = client.post("/api/projects", json={"title": "B4 isolated"}).json()
session = client.post(
"/api/agent/sessions",
json={
"project_id": project["id"],
"agent_key": stage,
},
).json()
runner._persist_message(session["id"], role="assistant", content=TEXT)
complete_task(session["id"], TEXT)
draft = client.get("/api/results/deliverables", params={"session_id": session["id"]}).json()[0]
return project["id"], session["id"], draft
def store(client, pid, draft, stage="script"):
return client.post(
"/api/results/store",
json={
"project_id": pid,
"stage_key": stage,
"deliverable_id": draft["id"],
"revision": draft["revision"],
},
)
def test_draft_save_refresh_conflict_and_scope(client):
pid, sid, draft = setup(client)
body = {"revision": 1, "content": TEXT + "新结尾。", "project_id": pid, "stage_key": "script"}
saved = client.patch(f"/api/results/deliverables/{draft['id']}", json=body)
assert saved.status_code == 200
assert saved.json()["revision"] == 2
versions = client.get(f"/api/results/deliverables/{draft['id']}/versions").json()
assert len(versions) == 1 and versions[0]["content"] == TEXT
assert (
client.get("/api/results/deliverables", params={"session_id": sid}).json()[0]
== saved.json()
)
assert client.patch(f"/api/results/deliverables/{draft['id']}", json=body).status_code == 409
assert store(client, pid, draft).status_code == 409
other = client.post("/api/projects", json={"title": "Other"}).json()["id"]
assert store(client, other, saved.json()).status_code == 409
body.update(revision=2, project_id=other)
assert client.patch(f"/api/results/deliverables/{draft['id']}", json=body).status_code == 409
assert client.get(f"/api/agent/sessions/{sid}").json()["task_status"] == "awaiting_confirm"
def test_store_idempotency_confirm_restore_and_preserve_snapshot(client):
pid, sid, draft = setup(client)
url = f"/api/workbench/projects/{pid}/stages/script"
old = {"text": TEXT + "已确认的旧版。"}
assert client.patch(url, json={"content": old}).status_code == 200
snapshot = client.post(url + "/confirm", json={}).json()["stage"]["confirmed_content"]
res = store(client, pid, draft)
assert res.status_code == 200, res.text
stage = res.json()["stage"]
assert stage["status"] == "awaiting_confirm"
assert stage["confirmed_content"] == snapshot
assert stage["content"]["text"] == TEXT
assert len(stage["incoming_results"]) == 1
assert store(client, pid, draft).json()["already_attached"] is True
assert len(store(client, pid, draft).json()["stage"]["incoming_results"]) == 1
assert client.get(f"/api/agent/sessions/{sid}").json()["task_status"] == "returned"
assert client.post(url + "/restore", json={}).json()["stage"]["content"] == snapshot
# A newer source revision is a new explicit handoff, never an automatic update.
draft = client.patch(
f"/api/results/deliverables/{draft['id']}",
json={
"revision": 1,
"content": TEXT + "新版本。",
"project_id": pid,
"stage_key": "script",
},
).json()
assert store(client, pid, draft).status_code == 200
confirmed = client.post(url + "/confirm", json={}).json()["stage"]["confirmed_content"]
assert confirmed["text"].endswith("新版本。")
assert len(confirmed["incoming_results"]) == 1
stages = client.get(f"/api/workbench/projects/{pid}").json()["stages"]
assert all(s["status"] == "todo" for s in stages if s["stage_key"] != "script")
def test_capture_idempotency_and_no_error_artifact(client):
_, sid, _ = setup(client)
complete_task(sid, TEXT)
assert len(client.get("/api/results/deliverables", params={"session_id": sid}).json()) == 1
runner._persist_message(sid, role="assistant", content="error", status="error")
complete_task(sid, "error")
assert len(client.get("/api/results/deliverables", params={"session_id": sid}).json()) == 1
def test_sandbox_assignment_and_running_stage(client):
pid, _, draft = setup(client)
conn = get_db(settings.db_path)
conn.execute("UPDATE project_stages SET status='running' WHERE project_id=?", (pid,))
conn.commit()
conn.close()
# Stage rows are created lazily; ensure they exist first.
client.get(f"/api/workbench/projects/{pid}")
conn = get_db(settings.db_path)
conn.execute("UPDATE project_stages SET status='running' WHERE project_id=?", (pid,))
conn.commit()
conn.close()
assert store(client, pid, draft).status_code == 409
assert client.get("/api/results/deliverables", params={"session_id": 99999}).json() == []
def test_media_paths_scope_and_empty_errors(client):
pid, _, _ = setup(client)
other = client.post("/api/projects", json={"title": "other"}).json()["id"]
path = settings.assets_dir / "b4.txt"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("isolated")
conn = get_db(settings.db_path)
job = conn.execute(
"INSERT INTO jobs(project_id,kind,status,output_paths_json) VALUES (?,'image','done',?)",
(pid, json.dumps([str(path)])),
).lastrowid
conn.commit()
conn.close()
body = {"job_id": job, "project_id": other, "stage_key": "assets"}
assert client.post("/api/results/store", json=body).status_code == 409
body["project_id"] = pid
result = client.post("/api/results/store", json=body)
assert result.status_code == 200, result.text
assert result.json()["stage"]["incoming_results"][0]["paths"] == [str(path)]
assert client.post("/api/results/store", json=body).json()["already_attached"]
assert (
client.post(
"/api/results/store", json={"project_id": pid, "stage_key": "bogus", "job_id": job}
).status_code
== 422
)
assert (
client.post(
"/api/results/store", json={"project_id": pid, "stage_key": "assets"}
).status_code
== 422
)
path.unlink()
assert client.post("/api/results/store", json=body).status_code == 404
def test_migration_twice_and_sqlite_backup_rollback(client, tmp_path):
pid, sid, draft = setup(client)
conn = sqlite3.connect(settings.db_path)
backup = sqlite3.connect(tmp_path / "before-b4.db")
conn.backup(backup)
backup.close()
conn.close()
asyncio.run(migrate_db(settings.db_path))
asyncio.run(migrate_db(settings.db_path))
assert client.get("/api/results/deliverables").json()[0]["content"] == draft["content"]
conn = get_db(settings.db_path)
# Old-code SQL continues reading the original schema and rows.
assert (
conn.execute(
"SELECT title,status,project_id FROM agent_sessions WHERE id=?", (sid,)
).fetchone()["project_id"]
== pid
)
assert conn.execute("PRAGMA integrity_check").fetchone()[0] == "ok"
conn.close()
+54 -15
View File
@@ -13,6 +13,7 @@ The shell is default-OFF; every guard branch here is a security contract:
from __future__ import annotations
import asyncio
import os
import sys
from pathlib import Path
@@ -25,6 +26,23 @@ from calliope.agent.harness.registry import ToolContext
PYTHON = Path(sys.executable).stem # bare name ("python") — the tool refuses paths
@pytest.fixture(autouse=True, scope="module")
def _interpreter_on_path():
"""Make the bare interpreter name resolvable via PATH (HEL-619).
The guard resolves the command with ``shutil.which`` against PATH —
running pytest as ``path/to/.venv/bin/python -m pytest`` (or on a box
with no ``python`` alias) leaves the stem unresolvable and every
exec-path test fails for environmental reasons only. Prepending the
interpreter's own bin dir (restored afterwards) pins the lookup to the
interpreter actually running the suite, on every OS.
"""
prev = os.environ.get("PATH", "")
os.environ["PATH"] = f"{Path(sys.executable).parent}{os.pathsep}{prev}"
yield
os.environ["PATH"] = prev
@pytest.fixture(autouse=True, scope="module")
def _never_touch_real_db():
"""Redirect data_dir before ANY write in this module (repo test rule)."""
@@ -210,12 +228,19 @@ def test_structured_shell_approval_allows(enabled):
def test_cwd_outside_workspace_denied(enabled):
"""Guard denial tests use PYTHON + a structured approval card (not
ffprobe): the interpreter resolves on every machine, and the card
clears the approval gate so the LATER containment check is what fires
(HEL-619 — ffprobe-absent boxes used to fail these for environmental
reasons)."""
registry, _ = build_harness()
sid = _session()
_answer_card(sid, "Yes, run it")
out = asyncio.run(
registry.execute(
_ctx(),
ToolContext(session_id=sid, project_id=None),
"run_command",
{"command": "ffprobe", "cwd": str(Path(settings_data_dir_home()))},
{"command": PYTHON, "cwd": str(settings_data_dir_home())},
)
)
assert out["reason_code"] == "guard_shell_outside_workspace"
@@ -230,11 +255,13 @@ def test_sensitive_arg_denied(enabled, tmp_path):
registry, _ = build_harness()
secret = tmp_path / "calliope_config.json"
secret.write_text("{}", encoding="utf-8")
sid = _session()
_answer_card(sid, "Yes, run it")
out = asyncio.run(
registry.execute(
_ctx(),
ToolContext(session_id=sid, project_id=None),
"run_command",
{"command": "ffprobe", "args": [str(secret)]},
{"command": PYTHON, "args": ["-c", "print('x')", str(secret)]},
)
)
assert out["reason_code"] == "guard_shell_denied_path"
@@ -244,11 +271,13 @@ def test_db_arg_denied(enabled, tmp_path):
registry, _ = build_harness()
db = tmp_path / "calliope.db"
db.write_bytes(b"")
sid = _session()
_answer_card(sid, "Yes, run it")
out = asyncio.run(
registry.execute(
_ctx(),
ToolContext(session_id=sid, project_id=None),
"run_command",
{"command": "ffprobe", "args": [str(db)]},
{"command": PYTHON, "args": ["-c", "print('x')", str(db)]},
)
)
assert out["reason_code"] == "guard_shell_denied_path"
@@ -256,11 +285,13 @@ def test_db_arg_denied(enabled, tmp_path):
def test_env_file_arg_denied(enabled):
registry, _ = build_harness()
sid = _session()
_answer_card(sid, "Yes, run it")
out = asyncio.run(
registry.execute(
_ctx(),
ToolContext(session_id=sid, project_id=None),
"run_command",
{"command": "ffprobe", "args": ["--", ".env"]},
{"command": PYTHON, "args": ["-c", "print('x')", "--", ".env"]},
)
)
assert out["reason_code"] == "guard_shell_denied_path"
@@ -270,11 +301,13 @@ def test_outside_absolute_arg_denied(enabled, tmp_path):
registry, _ = build_harness()
outside = tmp_path / "movie.mp4"
outside.write_bytes(b"00")
sid = _session()
_answer_card(sid, "Yes, run it")
out = asyncio.run(
registry.execute(
_ctx(),
ToolContext(session_id=sid, project_id=None),
"run_command",
{"command": "ffprobe", "args": [str(outside)]},
{"command": PYTHON, "args": ["-c", "print('x')", str(outside)]},
)
)
assert out["reason_code"] == "guard_shell_outside_workspace"
@@ -287,16 +320,22 @@ def test_inside_asset_arg_allowed(enabled):
registry, _ = build_harness()
ref = settings.assets_dir / "ref.mp4"
ref.write_bytes(b"00")
sid = _session()
_answer_card(sid, "Yes, run it")
out = asyncio.run(
registry.execute(
_ctx(),
ToolContext(session_id=sid, project_id=None),
"run_command",
{"command": "ffprobe", "args": ["-v", "error", str(ref)]},
{
# The asset path is a real argv entry the guard must allow
# (and the interpreter actually reads it).
"command": PYTHON,
"args": ["-c", "import sys; print(open(sys.argv[1]).read())", str(ref)],
},
)
)
# Not a real mp4 — ffprobe fails, but the GUARD must not have denied it.
assert out.get("reason_code") is None
assert out.get("exit_code") != 0 or out.get("ok") is False or out["ok"] is True
assert out["ok"] is True, out
assert "00" in out["stdout"]
def test_workspace_relative_cwd_allowed(enabled, tmp_path):
+220
View File
@@ -0,0 +1,220 @@
"""Skill management surface (HEL-613): create/edit/enable/assign/delete user
skills, version snapshots with restore, and the effective-skill rule —
a named-agent session sees exactly: globally enabled ∩ enabled binding for
its agent_key. Plain sessions keep the legacy behavior (global enable only)."""
from __future__ import annotations
from calliope.agent.skills_store import (
effective_skill_dirs,
skills_prompt_lines,
)
def _mk(client, name: str, bindings: dict | None = None, body: str = "正文 v1") -> dict:
payload = {
"description": f"{name} 描述",
"skill_type": "template",
"content": body,
"tags": ["a", "b"],
}
if bindings is not None:
payload["bindings"] = bindings
r = client.put(f"/api/agent/skills/{name}", json=payload)
assert r.status_code == 200, r.text
return r.json()
# ─────────────────────────────────────────────────────────────────────────
# Create / list
# ─────────────────────────────────────────────────────────────────────────
def test_create_skill_and_admin_list(client):
_mk(client, "my-style-kit", bindings={"style": True, "script": False})
skills = client.get("/api/agent/skills?all=true").json()
mine = next(s for s in skills if s["dir"] == "my-style-kit")
assert mine["name"] == "my-style-kit"
assert mine["is_enabled"] is True
assert mine["bindings"] == {"style": True, "script": False}
assert mine["assigned_count"] == 1
assert mine["type"] == "template"
assert mine["version"] == "1.0.0"
# Non-admin list stays capped + without admin fields (composer contract).
plain = client.get("/api/agent/skills").json()
mine_plain = next(s for s in plain if s["dir"] == "my-style-kit")
assert "bindings" not in mine_plain
def test_migration_seeded_builtin_bindings_for_all_agents(client):
"""At upgrade, every pre-existing skill (the builtins) is bound to all
five agents so agent chats keep the out-of-the-box experience."""
skills = client.get("/api/agent/skills?all=true").json()
builtin = next(s for s in skills if s["dir"] == "h3-video-prompt-enhancer")
assert set(builtin["bindings"].keys()) == {
"script",
"style",
"storyboard",
"assets",
"shot",
}
assert builtin["assigned_count"] == 5
def test_invalid_names_rejected(client):
for bad in ("../escape", "a/b", "", " ", ".hidden", "x" * 65):
r = client.put(f"/api/agent/skills/{bad}", json={"content": "x"})
assert r.status_code in (404, 422), bad
def test_invalid_type_rejected(client):
r = client.put("/api/agent/skills/ok-name", json={"content": "x", "skill_type": "wild"})
assert r.status_code == 422
# ─────────────────────────────────────────────────────────────────────────
# Effective rule: global enable ∩ per-agent binding
# ─────────────────────────────────────────────────────────────────────────
def test_effective_rule_for_agent_sessions(client):
_mk(client, "gated-skill", bindings={"style": True, "script": True})
assert "gated-skill" in effective_skill_dirs("style")
assert "gated-skill" in effective_skill_dirs("script")
assert "gated-skill" not in effective_skill_dirs("assets")
lines = skills_prompt_lines(agent_key="assets")
assert not any("gated-skill" in ln for ln in lines)
lines = skills_prompt_lines(agent_key="style")
assert any("gated-skill" in ln for ln in lines)
def test_unassigned_skill_invisible_everywhere(client):
_mk(client, "orphan-skill", bindings={})
for key in ("script", "style", "storyboard", "assets", "shot"):
assert "orphan-skill" not in effective_skill_dirs(key)
# Plain sessions still see it (legacy behavior).
assert "orphan-skill" in effective_skill_dirs(None)
def test_global_disable_hides_from_all_sessions(client):
_mk(client, "kill-switch", bindings={"style": True})
r = client.patch("/api/agent/skills/kill-switch", json={"is_enabled": False})
assert r.status_code == 200
assert "kill-switch" not in effective_skill_dirs("style")
assert "kill-switch" not in effective_skill_dirs(None)
r = client.patch("/api/agent/skills/kill-switch", json={"is_enabled": True})
assert "kill-switch" in effective_skill_dirs("style")
def test_binding_matrix_replace(client):
_mk(client, "matrix-skill", bindings={"style": True})
r = client.put(
"/api/agent/skills/matrix-skill/bindings",
json={"bindings": {"shot": True, "style": False}},
)
assert r.status_code == 200
assert "matrix-skill" in effective_skill_dirs("shot")
assert "matrix-skill" not in effective_skill_dirs("style")
def test_unknown_agent_key_in_bindings_rejected(client):
r = client.put(
"/api/agent/skills/some-skill/bindings",
json={"bindings": {"director": True}},
)
assert r.status_code == 422
def test_read_skill_tool_gates_by_assignment(client):
"""The read_skill tool denies skills not effective for the agent."""
from calliope.agent.harness.plugins.skills import t_read_skill
from calliope.agent.harness.registry import ToolContext
_mk(client, "gated-read", bindings={"style": True})
style_ctx = ToolContext(session_id=1, agent_key="style")
shot_ctx = ToolContext(session_id=2, agent_key="shot")
ok = asyncio_run(t_read_skill(style_ctx, {"name": "gated-read"}))
assert ok["ok"] is True
denied = asyncio_run(t_read_skill(shot_ctx, {"name": "gated-read"}))
assert denied["ok"] is False
assert "not assigned" in denied["error"]
def asyncio_run(coro):
import asyncio
return asyncio.run(coro)
# ─────────────────────────────────────────────────────────────────────────
# Versions: snapshot on save, list, restore
# ─────────────────────────────────────────────────────────────────────────
def test_edit_creates_snapshot_and_version_bumps(client):
first = _mk(client, "versioned", bindings={"script": True}, body="# v1 正文")
v1 = first["skill"]["version"]
second = _mk(client, "versioned", bindings={"script": True}, body="# v2 正文改了")
v2 = second["skill"]["version"]
assert v2 != v1 # auto-bumped when the editor leaves version empty
versions = client.get("/api/agent/skills/versioned/versions").json()
assert len(versions) == 1 # the pre-edit snapshot
assert "# v1 正文" in versions[0]["preview"]
# File now holds v2 content.
body = client.get(
"/api/agent/skills/versioned/file?path=SKILL.md"
).json()["content"]
assert "# v2 正文改了" in body
def test_restore_writes_snapshot_back(client):
_mk(client, "rollback", bindings={"script": True}, body="第一版")
_mk(client, "rollback", bindings={"script": True}, body="第二版")
versions = client.get("/api/agent/skills/rollback/versions").json()
old_id = versions[0]["id"] # newest first: snapshot of 第一版
r = client.post(f"/api/agent/skills/rollback/versions/{old_id}/restore")
assert r.status_code == 200
body = client.get("/api/agent/skills/rollback/file?path=SKILL.md").json()["content"]
assert "第一版" in body
# The restore itself snapshotted 第二版 — undoable.
versions = client.get("/api/agent/skills/rollback/versions").json()
assert any("第二版" in v["preview"] for v in versions)
def test_restore_unknown_version_404(client):
_mk(client, "nope-skill", bindings={"script": True})
r = client.post("/api/agent/skills/nope-skill/versions/99999/restore")
assert r.status_code == 404
# ─────────────────────────────────────────────────────────────────────────
# Delete
# ─────────────────────────────────────────────────────────────────────────
def test_delete_removes_everything(client):
_mk(client, "doomed", bindings={"script": True}, body="拜拜")
r = client.delete("/api/agent/skills/doomed")
assert r.status_code == 200
skills = client.get("/api/agent/skills?all=true").json()
assert all(s["dir"] != "doomed" for s in skills)
assert client.get("/api/agent/skills/doomed/versions").json() == []
assert "doomed" not in effective_skill_dirs("script")
# ─────────────────────────────────────────────────────────────────────────
# Agents endpoint skill counts
# ─────────────────────────────────────────────────────────────────────────
def test_agents_endpoint_counts_effective_skills(client):
_mk(client, "count-me", bindings={"style": True, "shot": True})
_mk(client, "count-me-disabled", bindings={"style": True})
client.patch("/api/agent/skills/count-me-disabled", json={"is_enabled": False})
agents = {a["key"]: a for a in client.get("/api/agent/agents").json()}
style_count = agents["style"]["skill_count"]
shot_count = agents["shot"]["skill_count"]
assert style_count >= 1 and shot_count >= 1
# A globally disabled skill never counts.
skills = client.get("/api/agent/skills?all=true").json()
disabled_dirs = {s["dir"] for s in skills if not s["is_enabled"]}
assert "count-me-disabled" in disabled_dirs
@@ -25,9 +25,19 @@ PNG_1PX = bytes.fromhex(
class _FakeComfyHandler(BaseHTTPRequestHandler):
"""Minimal ComfyUI: /system_stats, /prompt, /history/{id}, /view, /upload/image."""
"""Minimal ComfyUI: /system_stats, /prompt, /history/{id}, /view, /upload/image.
History outputs are keyed by the prompt's real output node id (what
ComfyUI does); a known output class that is absent falls back to
``server.forced_output_node`` (default "9"). Scripted tests can pin
``server.forced_output_node`` or drop entries via ``server.drop_outputs``.
"""
server_version = "FakeComfy/1.0"
KNOWN_OUTPUT_CLASSES = {
"SaveImage", "PreviewImage", "SaveImageAdvanced", "SaveImageWebsocket",
"SaveVideo", "VideoOutput", "VHS_VideoCombine", "AnimateDiffCombine",
}
def do_GET(self):
self.server.requests.append(self.path)
@@ -40,6 +50,9 @@ class _FakeComfyHandler(BaseHTTPRequestHandler):
},
)
return
if self.path == "/object_info":
self._json(200, dict(getattr(self.server, "object_info", None) or {}))
return
if self.path.startswith("/history/"):
pid = self.path[len("/history/") :]
entry = self.server.history.get(pid)
@@ -64,11 +77,17 @@ class _FakeComfyHandler(BaseHTTPRequestHandler):
prompt_id = uuid.uuid4().hex
outputs_key = self.server.outputs_key
out_entry = {
outputs_key: [{"filename": self.server.out_name, "subfolder": "", "type": "output"}]
outputs_key: [
{"filename": self.server.out_name, "subfolder": "", "type": "output"}
]
}
outputs = (
{} if getattr(self.server, "drop_outputs", False) else
{self._output_node(body): out_entry}
)
self.server.history[prompt_id] = {
"status": {"status_str": "success", "completed": True},
"outputs": {"9": out_entry},
"outputs": outputs,
"prompt": [],
}
self.server.prompts.append(json.loads(body.decode("utf-8") or "{}"))
@@ -80,6 +99,19 @@ class _FakeComfyHandler(BaseHTTPRequestHandler):
return
self._json(404, {"error": "not found"})
def _output_node(self, body: bytes) -> str:
forced = getattr(self.server, "forced_output_node", None)
if forced:
return forced
try:
prompt = json.loads(body.decode("utf-8") or "{}").get("prompt") or {}
except json.JSONDecodeError:
prompt = {}
for nid, node in prompt.items():
if isinstance(node, dict) and node.get("class_type") in self.KNOWN_OUTPUT_CLASSES:
return str(nid)
return "9"
def _json(self, status: int, payload: dict) -> None:
data = json.dumps(payload).encode("utf-8")
self.send_response(status)
@@ -109,6 +141,9 @@ def fake_comfy():
server.fail_prompt = False
server.out_name = "out-1.png"
server.outputs_key = "images"
server.forced_output_node = None
server.object_info = {}
server.drop_outputs = False
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
+449
View File
@@ -0,0 +1,449 @@
"""HEL-681: UI→API converter unit tests + ground-truth cross-checks.
The synthetic graphs pin the structural semantics (reroute / bypass / mute /
subgraph expansion / Set-Get variables / control_after_generate pairing);
the corpus section runs the real 146-file library and the repo's API exports.
"""
from __future__ import annotations
import copy
import json
from pathlib import Path
import pytest
from calliope.comfyui.lint import workflow_warnings
from calliope.comfyui.ui_convert import convert_ui_workflow
EXAMPLES = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
def _node(nid, ntype, *, mode=0, inputs=None, outputs=None, wv=None, wvn=None, title=None):
node = {"id": nid, "type": ntype, "mode": mode, "inputs": inputs or [], "order": 0}
if outputs is not None:
node["outputs"] = outputs
if wv is not None:
node["widgets_values"] = wv
if wvn is not None:
node["widgets_values_named"] = wvn
if title:
node["title"] = title
return node
def _in_slot(name, type_, link=None, widget=None):
entry = {"name": name, "type": type_, "link": link}
if widget:
entry["widget"] = {"name": widget}
return entry
def _out(name, type_, links=None):
return {"name": name, "type": type_, "links": links or [], "slot_index": 0}
def _base_graph():
"""text2image: loader → prompts/latent → KSampler → VAE → SaveImage."""
nodes = [
_node(
1,
"CheckpointLoaderSimple",
outputs=[_out("MODEL", "MODEL"), _out("CLIP", "CLIP"), _out("VAE", "VAE")],
wv=["model.safetensors"],
inputs=[_in_slot("ckpt_name", "COMBO", widget="ckpt_name")],
),
_node(
2,
"CLIPTextEncode",
inputs=[
_in_slot("clip", "CLIP", link=1),
_in_slot("text", "STRING", widget="text"),
],
outputs=[_out("CONDITIONING", "CONDITIONING")],
wv=["a cat"],
),
_node(
3,
"CLIPTextEncode",
inputs=[
_in_slot("clip", "CLIP", link=2),
_in_slot("text", "STRING", widget="text"),
],
outputs=[_out("CONDITIONING", "CONDITIONING")],
wv=["bad quality"],
),
_node(
4,
"EmptyLatentImage",
inputs=[
_in_slot("width", "INT", widget="width"),
_in_slot("height", "INT", widget="height"),
_in_slot("batch_size", "INT", widget="batch_size"),
],
outputs=[_out("LATENT", "LATENT")],
wv=[512, 512, 1],
),
_node(
5,
"KSampler",
inputs=[
_in_slot("model", "MODEL", link=3),
_in_slot("positive", "CONDITIONING", link=4),
_in_slot("negative", "CONDITIONING", link=5),
_in_slot("latent_image", "LATENT", link=6),
_in_slot("seed", "INT", widget="seed"),
_in_slot("steps", "INT", widget="steps"),
_in_slot("cfg", "FLOAT", widget="cfg"),
_in_slot("sampler_name", "COMBO", widget="sampler_name"),
_in_slot("scheduler", "COMBO", widget="scheduler"),
_in_slot("denoise", "FLOAT", widget="denoise"),
],
outputs=[_out("LATENT", "LATENT")],
# seed 42 + control "fixed" rides together, then five widgets
wv=[42, "fixed", 20, 7, "euler", "normal", 1.0],
),
_node(
6,
"VAEDecode",
inputs=[
_in_slot("samples", "LATENT", link=7),
_in_slot("vae", "VAE", link=8),
],
outputs=[_out("IMAGE", "IMAGE")],
),
_node(
7,
"SaveImage",
inputs=[
_in_slot("images", "IMAGE", link=9),
_in_slot("filename_prefix", "STRING", widget="filename_prefix"),
],
wv=["test"],
),
]
links = [
[1, 1, 1, 2, 0, "CLIP"],
[2, 1, 1, 3, 0, "CLIP"],
[3, 1, 0, 5, 0, "MODEL"],
[4, 2, 0, 5, 1, "CONDITIONING"],
[5, 3, 0, 5, 2, "CONDITIONING"],
[6, 4, 0, 5, 3, "LATENT"],
[7, 5, 0, 6, 0, "LATENT"],
[8, 1, 2, 6, 1, "VAE"],
[9, 6, 0, 7, 0, "IMAGE"],
]
return {"nodes": nodes, "links": links, "last_node_id": 7, "last_link_id": 9}
def _convert(graph):
return convert_ui_workflow(graph)
def test_clean_conversion_maps_everything():
result = _convert(_base_graph())
assert result.status == "ok"
assert result.needs_confirmation is False
wf = result.workflow
assert set(wf) == {"1", "2", "3", "4", "5", "6", "7"}
ks = wf["5"]
assert ks["class_type"] == "KSampler"
assert ks["inputs"]["seed"] == 42 # control_after_generate consumed silently
assert ks["inputs"]["steps"] == 20
assert ks["inputs"]["denoise"] == 1.0
assert ks["inputs"]["model"] == ["1", 0]
assert ks["inputs"]["latent_image"] == ["4", 0]
# lint on the converted graph must be error-free
assert not [w for w in workflow_warnings(wf) if w["level"] == "error"]
def test_upload_button_widgets_are_consumed_but_never_emitted():
graph = _base_graph()
load = _node(
8,
"LoadImage",
inputs=[
_in_slot("image", "COMBO", widget="image"),
_in_slot("upload", "IMAGEUPLOAD", widget="upload"),
],
outputs=[_out("IMAGE", "IMAGE")],
wv=["sample.png", "image"],
)
save = _node(
9,
"PreviewImage",
inputs=[
_in_slot("images", "IMAGE", link=20),
_in_slot("filename_prefix", "STRING", widget="filename_prefix"),
],
wv=["preview"],
)
graph["nodes"] += [load, save]
graph["links"].append([20, 8, 0, 9, 0, "IMAGE"])
result = _convert(graph)
assert result.workflow["8"]["inputs"] == {"image": "sample.png"}
def test_reroute_pass_through():
graph = _base_graph()
reroute = _node(8, "Reroute", inputs=[_in_slot("(in)", "*", link=9)])
graph["nodes"].append(reroute)
# SaveImage now fed by the reroute instead of VAEDecode directly
graph["nodes"][6]["inputs"][0]["link"] = 10
graph["links"].append([10, 8, 0, 7, 0, "IMAGE"])
result = _convert(graph)
assert result.workflow["7"]["inputs"]["images"] == ["6", 0]
assert "8" not in result.workflow
def test_bypass_pass_through_and_mute_drop():
graph = _base_graph()
# bypass the KSampler: VAEDecode.samples must fall back to the latent
graph["nodes"][4]["mode"] = 4
result = _convert(graph)
assert result.workflow["6"]["inputs"]["samples"] == ["4", 0]
# mute the negative prompt: the link input is dropped with a warning
graph = _base_graph()
graph["nodes"][2]["mode"] = 2
result = _convert(graph)
assert "negative" not in result.workflow["5"]["inputs"]
assert any(i.code == "muted_input_dropped" for i in result.issues)
def test_widget_entry_with_muted_upstream_falls_back_to_value():
graph = _base_graph()
# feed seed from a muted PrimitiveInt via a converted widget input
prim = _node(
9,
"PrimitiveInt",
mode=2,
inputs=[_in_slot("value", "INT", widget="value"), _in_slot("control", "COMBO", widget="control")],
wv=[7],
)
graph["nodes"].append(prim)
graph["nodes"][4]["inputs"][4]["link"] = 11 # seed ← primitive
graph["links"].append([11, 9, 0, 5, 4, "INT"])
result = _convert(graph)
assert result.workflow["5"]["inputs"]["seed"] == 42
def test_subgraph_expansion_and_boundary_wiring():
graph = _base_graph()
# pack the EmptyLatentImage into a subgraph exposing width/height widgets
del graph["nodes"][3]
graph["nodes"] = [n for n in graph["nodes"] if n["id"] != 4]
sub_id = "aaaa-bbbb-cccc-dddd-eeee"
packed = _node(
20,
sub_id,
inputs=[
_in_slot("width", "INT", widget="width"),
_in_slot("height", "INT", widget="height"),
],
outputs=[_out("LATENT", "LATENT")],
wv=[640, 384],
)
graph["nodes"].append(packed)
# KSampler latent_image ← packed output
graph["links"] = [l for l in graph["links"] if l[0] != 6]
graph["links"].append([6, 20, 0, 5, 3, "LATENT"])
graph["definitions"] = {
"subgraphs": [
{
"id": sub_id,
"name": "Latent",
"inputs": [
{"name": "width", "type": "INT"},
{"name": "height", "type": "INT"},
],
"outputs": [{"name": "LATENT", "type": "LATENT"}],
"inputNode": {"id": -10},
"outputNode": {"id": -20},
"nodes": [
_node(
5,
"EmptyLatentImage",
inputs=[
_in_slot("width", "INT", widget="width", link=101),
_in_slot("height", "INT", widget="height", link=102),
_in_slot("batch_size", "INT", widget="batch_size"),
],
outputs=[_out("LATENT", "LATENT")],
wv=[512, 512, 1],
),
],
"links": [
{"id": 101, "origin_id": -10, "origin_slot": 0, "target_id": 5, "target_slot": 0, "type": "INT"},
{"id": 102, "origin_id": -10, "origin_slot": 1, "target_id": 5, "target_slot": 1, "type": "INT"},
{"id": 103, "origin_id": 5, "origin_slot": 0, "target_id": -20, "target_slot": 0, "type": "LATENT"},
],
}
]
}
result = _convert(graph)
wf = result.workflow
assert "20:5" in wf # qualified packed:inner id, ComfyUI's own scheme
inner = wf["20:5"]
assert inner["class_type"] == "EmptyLatentImage"
# exposed widget values from the packed instance, not the inner defaults
assert inner["inputs"]["width"] == 640
assert inner["inputs"]["height"] == 384
assert inner["inputs"]["batch_size"] == 1
assert wf["5"]["inputs"]["latent_image"] == ["20:5", 0]
# now wire the packed width slot from outside: boundary becomes a link
res_node = _node(
21,
"ResolutionSelector",
inputs=[_in_slot("aspect_ratio", "COMBO", widget="aspect_ratio")],
outputs=[_out("width", "INT"), _out("height", "INT")],
wv=["1:1"],
)
graph["nodes"].append(res_node)
graph["nodes"][-2]["inputs"][0]["link"] = 12 # packed width ← selector
graph["links"].append([12, 21, 0, 20, 0, "INT"])
wf2 = _convert(graph).workflow
assert wf2["20:5"]["inputs"]["width"] == ["21", 0]
assert wf2["20:5"]["inputs"]["height"] == 384
def test_set_get_variables_resolve_to_captured_source():
graph = _base_graph()
setter = _node(8, "SetNode", inputs=[_in_slot("*", "*", link=8)], wv=["the_vae"])
getter = _node(9, "GetNode", wv=["the_vae"], outputs=[_out("VAE", "VAE")])
graph["nodes"] += [setter, getter]
# VAEDecode.vae now fed via the variable
graph["nodes"][5]["inputs"][1]["link"] = 13
graph["links"] = [l for l in graph["links"] if l[0] != 8]
graph["links"] += [
[8, 1, 2, 8, 0, "VAE"],
[13, 9, 0, 6, 1, "VAE"],
]
result = _convert(graph)
assert "8" not in result.workflow and "9" not in result.workflow
assert result.workflow["6"]["inputs"]["vae"] == ["1", 2]
def test_unknown_widget_order_lands_in_needs_confirmation():
graph = _base_graph()
# an old-frontend node: positional widgets, no widget markers, no registry
graph["nodes"].append(
_node(10, "MysteryCustomNode", inputs=[], outputs=[_out("IMAGE", "IMAGE")], wv=[1, 2, 3])
)
save = _node(
11,
"PreviewImage",
inputs=[_in_slot("images", "IMAGE", link=30)],
wv=["preview"],
)
graph["nodes"].append(save)
graph["links"].append([30, 10, 0, 11, 0, "IMAGE"])
result = _convert(graph)
assert result.needs_confirmation is True
assert any(i.code == "widget_unmapped" for i in result.issues)
# the node is still converted structurally — the report carries the doubt
assert "10" in result.workflow
def test_not_ui_format_fails_fast():
result = convert_ui_workflow({"3": {"class_type": "SaveImage", "inputs": {}}})
assert result.status == "fail"
assert result.workflow is None
# ---------------------------------------------------------------------------
# Real corpus: the 146 UI files + ground-truth API cross-checks (对拍).
# ---------------------------------------------------------------------------
pytestmark_convert = pytest.mark.skipif(
not EXAMPLES.is_dir(), reason="example_ComfyUI_workflows not checked out"
)
@pytest.mark.skipif(not EXAMPLES.is_dir(), reason="example library missing")
def test_corpus_146_files_all_convert():
ui_files = [
p
for p in sorted(EXAMPLES.rglob("*.json"))
if isinstance(json.loads(p.read_text(encoding="utf-8")), dict)
and isinstance(json.loads(p.read_text(encoding="utf-8")).get("nodes"), list)
]
assert len(ui_files) == 146
statuses = {"ok": 0, "warn": 0, "fail": 0}
needs_confirmation = 0
for path in ui_files:
data = json.loads(path.read_text(encoding="utf-8"))
result = convert_ui_workflow(data)
statuses[result.status] += 1
needs_confirmation += result.needs_confirmation
assert result.workflow, f"{path.name} produced no workflow"
# structural sanity: no dangling links in any conversion
nodes = result.workflow
for nid, node in nodes.items():
for name, val in node.get("inputs", {}).items():
if isinstance(val, list):
assert str(val[0]) in nodes, f"{path.name}: {nid}.{name} dangles"
assert statuses["fail"] == 0
assert statuses["ok"] + statuses["warn"] == 146
# files whose widget order cannot be trusted are flagged, never hidden
assert 0 < needs_confirmation < 10
@pytest.mark.skipif(not EXAMPLES.is_dir(), reason="example library missing")
def test_cross_check_against_ground_truth_exports():
"""The H3 t2v UI export converts to the same node classes the author's
own API export carries; shared classes agree on scalar input names."""
ui = json.loads(
(EXAMPLES / "4-视频生成/Minimax-h3/minimax_h3_t2v_文生视频.json").read_text(encoding="utf-8")
)
api = json.loads(
(EXAMPLES / "minimax_h3-Turbo_Text2Video_20260813_API.json").read_text(encoding="utf-8")
)
result = convert_ui_workflow(ui)
assert result.status in ("ok", "warn")
converted = result.workflow
api_classes = {n["class_type"] for n in api.values()}
# Every scalar name the conversion emits for a shared class must be an
# input name the author's own export knows — wiring may differ between
# versions (a muted branch falls back to its widget value), but the NAME
# mapping must never invent or swap inputs.
for cls in api_classes & {n["class_type"] for n in converted.values()}:
api_names = {
key
for n in api.values()
if n["class_type"] == cls
for key in n.get("inputs", {})
}
conv_names = {
key
for n in converted.values()
if n["class_type"] == cls
for key, v in n.get("inputs", {}).items()
if not isinstance(v, list)
}
assert conv_names <= api_names, f"class {cls} invents inputs: {conv_names - api_names}"
@pytest.mark.skipif(not EXAMPLES.is_dir(), reason="example library missing")
def test_packed_krea2_conversion_shape_matches_export_scheme():
"""The krea2 turbo file (packed subgraph) converts ok, expands inner nodes
under `<packed>:<inner>` ids and maps the sampler widgets exactly — the
same scheme the author's own Krea2 API export uses (ids like "171:153")."""
ui = json.loads(
(EXAMPLES / "3-图像生成/krea2/krea2_turbo_文生图.json").read_text(encoding="utf-8")
)
api = json.loads(
(EXAMPLES / "Krea2_t2i_20260818_API.json").read_text(encoding="utf-8")
)
result = convert_ui_workflow(ui)
assert result.status == "ok"
assert any(":" in nid for nid in result.workflow) # expanded subgraph nodes
assert any("171:" in nid for nid in api) # ground truth uses the same scheme
ks = [
n
for n in result.workflow.values()
if n["class_type"] == "KSampler" and not isinstance(n["inputs"].get("seed"), list)
]
assert ks, "inner KSampler with widget-mapped seed expected"
assert {"seed", "steps", "cfg", "sampler_name", "scheduler", "denoise"} <= set(
ks[0]["inputs"]
)
@@ -0,0 +1,371 @@
"""HEL-638: unknown custom output nodes close the loop — no name-based
auto-typing, feature-less sinks become mappable candidates, a usage choice
alone never bypasses the output designation, and generation harvests (and
checks) exactly the nodes the user mapped.
Two brand-new class names drive the cases:
* ``NebulaGlaxVideoVault`` — screams "video" in its name and carries a
filename_prefix; before HEL-638 the fuzzy name match auto-typed it video
and enabled the workflow.
* ``QuarkMuffinDisplay`` — no save widget, no media link name; before
HEL-638 such a graph only reported ``no_output`` with no way to map.
"""
from __future__ import annotations
import json
from pathlib import Path
from calliope.config import settings
from calliope.db import get_db, migrate_db
from tests.test_studio_generation import _real_run, _wait_done
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
def _vault_workflow() -> dict:
return {
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
"7": {
"class_type": "GlaxSamplerNode",
"inputs": {"prompt": ["1", 0], "length": 121},
},
"12": {
"class_type": "NebulaGlaxVideoVault",
"inputs": {"filename_prefix": "anim", "frames": ["7", 0]},
},
}
def _muffin_workflow() -> dict:
return {
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
"7": {
"class_type": "GlaxSamplerNode",
"inputs": {"prompt": ["1", 0], "length": 121},
},
"15": {"class_type": "QuarkMuffinDisplay", "inputs": {"payload": ["7", 0]}},
}
def _analyze(client, wf: dict) -> dict:
r = client.post("/api/workflows/analyze", json={"workflow_json": wf})
assert r.status_code == 200, r.text
return r.json()
# ---------------------------------------------------------------------------
# 1. Unknown outputs stay pending — names never decide (gap 1)
# ---------------------------------------------------------------------------
def test_video_semantic_unknown_node_is_never_auto_typed(client):
body = _analyze(client, _vault_workflow())
entry = next(o for o in body["usage_evidence"]["outputs"] if o["nodeId"] == "12")
assert entry["evidence"] == "unknown_node"
assert entry["kind"] is None # the user picks the media type
assert entry["suggested_kind"] == "video" # only a form default
assert body["needs_mapping"] is True
assert body["usage"] is None and body["usage_ambiguous"] is True
assert "指定实际输出节点" in body["usage_problem"]
codes = {(w["level"], w["code"]) for w in body["warnings"]}
assert ("warning", "unknown_output_node") in codes
assert ("error", "no_output") not in codes
def test_choosing_a_usage_alone_does_not_bypass_output_mapping(client):
"""create/PATCH with a usage but no output map keeps the row disabled."""
r = client.post(
"/api/workflows",
json={"name": "未知·视频语义", "workflow_json": _vault_workflow(),
"usage": "text2video", "usage_source": "user"},
)
assert r.status_code == 200, r.text
wf = r.json()
assert wf["usage"] == "text2video" and wf["usage_source"] == "user"
assert wf["is_enabled"] is False # usage set, outputs still unconfirmed
assert wf["needs_mapping"] is True and not wf["output_map"]
gen = client.post(
"/api/playground/generate",
json={"workflow_id": wf["id"], "input_values": {"1": "x"}},
)
assert gen.status_code == 400
# forcing the switch on is refused with the mapping reason
r = client.patch(f"/api/workflows/{wf['id']}", json={"is_enabled": True})
assert r.status_code == 400
assert "指定" in r.json()["detail"] and "输出节点" in r.json()["detail"]
# ---------------------------------------------------------------------------
# 2. Feature-less unknown sinks become mappable candidates (gap 2)
# ---------------------------------------------------------------------------
def test_featureless_unknown_node_is_a_candidate_not_a_hard_error(client):
body = _analyze(client, _muffin_workflow())
entry = next(o for o in body["usage_evidence"]["outputs"] if o["nodeId"] == "15")
assert entry["evidence"] == "unknown_node" and entry["kind"] is None
assert body["needs_mapping"] is True
codes = {(w["level"], w["code"]) for w in body["warnings"]}
assert ("warning", "unknown_output_node") in codes
assert ("error", "no_output") not in codes
def test_truly_outputless_graph_still_reports_the_chinese_error(client):
wf = {
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "p"}},
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["1", 0]}},
}
body = _analyze(client, wf)
assert body["usage"] is None and body["needs_mapping"] is False
assert "未在图中找到输出节点" in body["usage_problem"]
# ---------------------------------------------------------------------------
# 3. Manual mapping → enable → scoped generation (fake ComfyUI round trip)
# ---------------------------------------------------------------------------
def test_manual_mapping_enables_and_extracts_from_the_mapped_node(client, fake_comfy):
fake_comfy.outputs_key = "gifs"
fake_comfy.out_name = "clip.mp4"
fake_comfy.forced_output_node = "12" # the mapped node reports results
with _real_run(client, fake_comfy):
r = client.post(
"/api/workflows",
json={
"name": "映射·视频库",
"workflow_json": _vault_workflow(),
"usage": "text2video",
"usage_source": "user",
"output_map": [{"nodeId": "12", "kind": "video"}],
"output_map_source": "user",
},
)
assert r.status_code == 200, r.text
wf = r.json()
assert wf["is_enabled"] is True
assert wf["output_map"] == [{"nodeId": "12", "kind": "video"}]
assert wf["output_map_source"] == "user"
r = client.post(
"/api/playground/generate",
json={"workflow_id": wf["id"], "input_values": {"1": "星空延时"},
"source": "studio-video"},
)
assert r.status_code == 200, r.text
job = _wait_done(client, r.json()["job"]["id"])
assert job["status"] == "done", job.get("error")
assert job["output_paths"][0].endswith(".mp4")
def test_mapped_node_returning_nothing_fails_for_real(client, fake_comfy):
"""Results that only land on an unmapped node are NOT a completion."""
fake_comfy.forced_output_node = "7" # something else reports results
with _real_run(client, fake_comfy):
r = client.post(
"/api/workflows",
json={
"name": "映射·空手而归",
"workflow_json": _muffin_workflow(),
"usage": "text2image",
"usage_source": "user",
"output_map": [{"nodeId": "15", "kind": "image"}],
},
)
assert r.status_code == 200 and r.json()["is_enabled"] is True
r = client.post(
"/api/playground/generate",
json={"workflow_id": r.json()["id"], "input_values": {"1": "x"}},
)
assert r.status_code == 200, r.text
job = _wait_done(client, r.json()["job"]["id"])
assert job["status"] == "failed"
assert "没有返回任何图片或视频" in job["error"]
assert "15" in job["error"] # names the node it waited on
assert job["output_paths"] == []
def test_media_type_mismatch_fails_for_real(client, fake_comfy):
"""A video mapping that only yields a png is a real failure."""
fake_comfy.outputs_key = "images"
fake_comfy.out_name = "still.png"
fake_comfy.forced_output_node = "12"
with _real_run(client, fake_comfy):
r = client.post(
"/api/workflows",
json={
"name": "映射·类型不符",
"workflow_json": _vault_workflow(),
"usage": "text2video",
"usage_source": "user",
"output_map": [{"nodeId": "12", "kind": "video"}],
},
)
wid = r.json()["id"]
r = client.post(
"/api/playground/generate",
json={"workflow_id": wid, "input_values": {"1": "x"}},
)
job = _wait_done(client, r.json()["job"]["id"])
assert job["status"] == "failed"
assert "媒体类型与映射不符" in job["error"]
assert job["output_paths"] == []
# ---------------------------------------------------------------------------
# 4. Bad / cancelled mappings
# ---------------------------------------------------------------------------
def test_invalid_mappings_are_rejected_in_chinese(client):
r = client.post(
"/api/workflows",
json={"name": "坏映射", "workflow_json": _vault_workflow(),
"usage": "text2video",
"output_map": [{"nodeId": "999", "kind": "video"}]},
)
assert r.status_code == 422 and "不存在" in r.json()["detail"]
r = client.post(
"/api/workflows",
json={"name": "坏映射2", "workflow_json": _vault_workflow(),
"usage": "text2video",
"output_map": [{"nodeId": "12", "kind": "audio"}]},
)
assert r.status_code == 422 and "媒体类型" in r.json()["detail"]
# a prompt/parameter node can never be an output
r = client.post(
"/api/workflows",
json={"name": "坏映射3", "workflow_json": _vault_workflow(),
"usage": "text2video",
"output_map": [{"nodeId": "1", "kind": "video"}]},
)
assert r.status_code == 422 and "不能作为输出节点" in r.json()["detail"]
wid = client.post(
"/api/workflows",
json={"name": "先好后坏", "workflow_json": _vault_workflow(),
"usage": "text2video",
"output_map": [{"nodeId": "12", "kind": "video"}]},
).json()["id"]
r = client.patch(
f"/api/workflows/{wid}", json={"output_map": [{"nodeId": "42", "kind": "image"}]}
)
assert r.status_code == 422 and "不存在" in r.json()["detail"]
def test_clearing_the_mapping_resets_to_pending(client):
wid = client.post(
"/api/workflows",
json={"name": "映射后清空", "workflow_json": _vault_workflow(),
"usage": "text2video",
"output_map": [{"nodeId": "12", "kind": "video"}]},
).json()["id"]
wf = client.get(f"/api/workflows/{wid}").json()
assert wf["is_enabled"] is True
r = client.patch(f"/api/workflows/{wid}", json={"output_map": []})
assert r.status_code == 200
wf = r.json()
assert wf["is_enabled"] is False and not wf["output_map"]
assert wf["usage"] == "text2video" # the usage decision survives
gen = client.post(
"/api/playground/generate",
json={"workflow_id": wid, "input_values": {"1": "x"}},
)
assert gen.status_code == 400
# ---------------------------------------------------------------------------
# 5. Saved mappings survive reanalyze / field edits / migration
# ---------------------------------------------------------------------------
def test_saved_mapping_survives_reanalyze_and_field_edits(client):
r = client.post(
"/api/workflows",
json={"name": "重分析·保映射", "workflow_json": _vault_workflow(),
"usage": "text2video",
"output_map": [{"nodeId": "12", "kind": "video"}]},
)
wid = r.json()["id"]
r = client.patch(f"/api/workflows/{wid}", json={"name": "改名不影响映射",
"description": "编辑其他字段"})
wf = r.json()
assert wf["output_map"] == [{"nodeId": "12", "kind": "video"}]
assert wf["output_map_source"] == "user" and wf["is_enabled"] is True
r = client.post(f"/api/workflows/{wid}/reanalyze")
assert r.status_code == 200
wf = r.json()
assert wf["output_map"] == [{"nodeId": "12", "kind": "video"}]
assert wf["output_map_source"] == "user"
assert wf["usage"] == "text2video" and wf["usage_source"] == "user"
assert wf["is_enabled"] is True
def test_sql_forced_enabled_row_without_mapping_is_blocked(client):
"""Backend-direct generation on an unconfirmed row is refused."""
wid = client.post(
"/api/workflows",
json={"name": "强开", "workflow_json": _vault_workflow(),
"usage": "text2video"},
).json()["id"]
conn = get_db(settings.db_path)
try:
conn.execute("UPDATE workflows SET is_enabled = 1 WHERE id = ?", (wid,))
conn.commit()
finally:
conn.close()
gen = client.post(
"/api/playground/generate",
json={"workflow_id": wid, "input_values": {"1": "x"}},
)
assert gen.status_code == 400
assert "输出节点" in gen.json()["detail"]
def test_migration_parks_unknown_output_rows_and_keeps_trusted_ones(client):
import asyncio
conn = get_db(settings.db_path)
try:
# Pre-HEL-638 shape: user confirmed a usage on an unknown-output
# graph and the old PATCH re-enabled it.
conn.execute(
"INSERT INTO workflows (name, kind, workflow_json, input_schema,"
" output_schema, is_enabled, usage, usage_source)"
" VALUES (?, 'video', ?, '[]', '[]', 1, 'text2video', 'user')",
("存量·未知输出", json.dumps(_vault_workflow())),
)
conn.commit()
finally:
conn.close()
asyncio.run(migrate_db(settings.db_path)) # idempotent re-run
wfs = {w["name"]: w for w in client.get("/api/workflows").json()}
parked = wfs["存量·未知输出"]
assert parked["is_enabled"] is False
assert parked["usage"] == "text2video" # decision preserved
assert not parked["output_map"] and parked["needs_mapping"] is True
# A trusted five-type import keeps working untouched.
r = client.post(
"/api/workflows",
json={"name": "存量·可信", "kind": "image",
"workflow_json": json.loads(
(FIXTURES / "qwen_image_t2i_API.json").read_text(encoding="utf-8")
)},
)
assert r.status_code == 200
trusted = next(w for w in client.get("/api/workflows").json() if w["id"] == r.json()["id"])
assert trusted["is_enabled"] is True
assert trusted["output_map_source"] == "auto"
assert any(e["nodeId"] == "60" and e["kind"] == "image" for e in trusted["output_map"])
@@ -0,0 +1,289 @@
"""HEL-623: workflows exported straight from ComfyUI (no (Input:…) title tags)
still expose a prompt and their fillable widgets, patch back onto the right
widget, and run the full 导入→填参→生成→历史 chain against a fake ComfyUI."""
from __future__ import annotations
import json
from pathlib import Path
from calliope.comfyui.parser import parse_dynamic_inputs
from calliope.comfyui.patcher import patch_workflow
from calliope.comfyui.smart_fill import ref_image_slots
from calliope.config import settings
from calliope.db import get_db
from tests.test_studio_generation import ( # noqa: F401 — fixture re-export
_import_workflow,
_real_run,
_wait_done,
fake_comfy,
)
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
EXAMPLES = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
def _load(name: str) -> dict:
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
def _by_role(inputs: list[dict]) -> dict[str, dict]:
return {i["role"]: i for i in inputs if i.get("role")}
def test_native_qwen_t2i_exposes_prompt_and_sampler_widgets():
inputs = parse_dynamic_inputs(_load("qwen_image_t2i_API.json"))
roles = _by_role(inputs)
assert roles["prompt"]["nodeId"] == "6"
assert roles["prompt"]["required"] is True
assert roles["prompt"]["defaultValue"].startswith("一只橘猫")
assert roles["negative"]["nodeId"] == "7"
assert roles["seed"]["nodeId"] == "3@seed"
assert roles["steps"]["nodeId"] == "3@steps"
assert roles["cfg"]["nodeId"] == "3@cfg"
assert roles["width"]["nodeId"] == "58@width"
assert roles["height"]["nodeId"] == "58@height"
assert roles["batch"]["nodeId"] == "58@batch_size"
assert all(i["auto"] for i in inputs)
assert [i for i in inputs if i["required"]] == [roles["prompt"]]
def test_native_qwen_edit_detects_image_and_mask_slots():
inputs = parse_dynamic_inputs(_load("qwen_image_edit_mask_API.json"))
roles = _by_role(inputs)
# TextEncodeQwenImageEditPlus writes `prompt`, not the class-derived field
assert roles["prompt"]["nodeId"] == "76@prompt"
assert roles["negative"]["nodeId"] == "77@prompt"
media = {i["nodeId"]: i for i in inputs if i["kind"] == "image"}
assert set(media) == {"78", "95"}
assert media["95"]["role"] == "mask"
assert "mask" in media["95"]["label"].lower() or "遮罩" in media["95"]["label"]
# HEL-633: a wired input image must be uploaded (the export's own file name
# rarely exists on the target ComfyUI); the mask stays optional
assert media["78"]["required"] and not media["95"]["required"]
def test_untagged_h3_video_detects_prompt_duration_seed():
inputs = parse_dynamic_inputs(_load("minimax_h3_t2v_untagged_API.json"))
roles = _by_role(inputs)
assert roles["prompt"]["nodeId"] == "6005"
assert roles["duration"]["nodeId"] == "221"
assert roles["seed"]["nodeId"] == "217@noise_seed"
def test_real_qwen21_t2i_splits_single_encoder_into_both_prompts():
"""白栖知的 wen-sheng-tu.json:一个 TextEncodeQwenImage21 同时喂 positive/negative."""
wf = _load("wen_sheng_tu_API.json")
inputs = parse_dynamic_inputs(wf)
roles = _by_role(inputs)
assert roles["prompt"]["nodeId"] == "459:452@prompt"
assert roles["prompt"]["label"] == "正向提示词"
assert roles["prompt"]["required"] is True
assert roles["prompt"]["defaultValue"].startswith("Greyscale fashion editorial")
assert roles["negative"]["nodeId"] == "459:452@negative_prompt"
assert roles["negative"]["label"] == "反向提示词"
assert roles["negative"]["required"] is False
assert roles["seed"]["nodeId"] == "459:458@seed"
assert roles["steps"]["nodeId"] == "459:458@steps"
assert roles["cfg"]["nodeId"] == "459:458@cfg"
assert roles["batch"]["nodeId"] == "459:456@batch_size"
# width/height are wired from the resolution selector — not exposed
assert "width" not in roles and "height" not in roles
assert [i for i in inputs if i["required"]] == [roles["prompt"]]
patched = patch_workflow(
wf, {roles["prompt"]["nodeId"]: "新正向", roles["negative"]["nodeId"]: "模糊"}
)
enc = patched["459:452"]["inputs"]
assert enc["prompt"] == "新正向"
assert enc["negative_prompt"] == "模糊"
assert enc["clip"] == ["459:453", 0]
def test_real_h3_t2v_duration_from_expression_primitive():
"""wen-sheng-shipin.json:无标题 PrimitiveFloat 经表达式换算帧数 → 时长(秒)."""
inputs = parse_dynamic_inputs(_load("wen_sheng_shipin_API.json"))
roles = _by_role(inputs)
assert roles["prompt"]["nodeId"] == "1"
assert roles["prompt"]["required"] is True
assert roles["duration"]["nodeId"] == "133"
assert roles["duration"]["label"] == "时长(秒)"
assert roles["duration"]["defaultValue"] == 10
assert roles["seed"]["nodeId"] == "129@noise_seed"
assert roles["steps"]["nodeId"] == "124@steps"
assert "negative" not in roles
def _encoder_graph(encoder: dict, positive_slot: list, negative_slot: list) -> dict:
return {
"1": encoder,
"2": {
"class_type": "KSampler",
"inputs": {"seed": 1, "steps": 20, "cfg": 4, "positive": positive_slot,
"negative": negative_slot},
},
}
def test_custom_encoder_with_text_and_negative_prompt_is_split():
wf = _encoder_graph(
{"class_type": "MyTextEncodeDual", "inputs": {"text": "猫", "negative_prompt": "糊"},
"_meta": {"title": "MyTextEncodeDual"}},
["1", 0],
["1", 1],
)
roles = _by_role(parse_dynamic_inputs(wf))
assert roles["prompt"]["nodeId"] == "1"
assert roles["prompt"]["defaultValue"] == "猫"
assert roles["negative"]["nodeId"] == "1@negative_prompt"
def test_clip_encoder_wired_to_both_positive_and_negative_stays_positive():
wf = _encoder_graph(
{"class_type": "CLIPTextEncode", "inputs": {"text": "猫"},
"_meta": {"title": "CLIP Text Encode"}},
["1", 0],
["1", 0],
)
roles = _by_role(parse_dynamic_inputs(wf))
assert roles["prompt"]["nodeId"] == "1"
assert "negative" not in roles
def test_tagged_workflows_are_unchanged():
for path in sorted(EXAMPLES.glob("*.json")):
inputs = parse_dynamic_inputs(json.loads(path.read_text(encoding="utf-8")))
assert inputs, path.name
assert not any(i.get("auto") for i in inputs), path.name
assert all("@" not in i["nodeId"] for i in inputs), path.name
def test_patch_field_keys_write_named_widget_and_keep_links():
wf = _load("qwen_image_t2i_API.json")
patched = patch_workflow(
wf,
{"6": "新的提示词", "3@seed": 7, "3@steps": 12, "58@width": 1024, "3@model": "x"},
)
assert patched["6"]["inputs"]["text"] == "新的提示词"
assert patched["3"]["inputs"]["seed"] == 7
assert patched["3"]["inputs"]["steps"] == 12
assert patched["58"]["inputs"]["width"] == 1024
# linked input stays wired; the base workflow is untouched
assert patched["3"]["inputs"]["model"] == ["66", 0]
assert wf["3"]["inputs"]["seed"] == 1125488487853216
def test_smart_fill_sort_accepts_subgraph_and_field_ids():
slots = ref_image_slots(
[
{"nodeId": "171:12", "role": "image", "kind": "image"},
{"nodeId": "9", "role": "image", "kind": "image"},
{"nodeId": "3@image", "role": "image", "kind": "image"},
]
)
assert [s["nodeId"] for s in slots] == ["3@image", "9", "171:12"]
def test_list_backfills_schema_for_workflows_imported_before_detection(client):
conn = get_db(settings.db_path)
try:
cur = conn.execute(
"INSERT INTO workflows"
" (name, kind, workflow_json, input_schema, output_schema, is_enabled)"
" VALUES (?, 'image', ?, '[]', '[]', 1)",
("旧导入·Qwen", json.dumps(_load("qwen_image_t2i_API.json"))),
)
conn.commit()
wf_id = cur.lastrowid
finally:
conn.close()
wf = client.get(f"/api/workflows/{wf_id}").json()
assert _by_role(wf["input_schema"])["prompt"]["nodeId"] == "6"
listed = next(w for w in client.get("/api/workflows").json() if w["id"] == wf_id)
assert len(listed["input_schema"]) == len(wf["input_schema"])
def test_native_workflow_full_chain(client, fake_comfy): # noqa: F811
"""导入原生 Qwen 工作流 → 填参 → 生成 → ComfyUI 收到改写后的节点 → 历史可见."""
with _real_run(client, fake_comfy):
wf_id = _import_workflow(
client, "原生·Qwen 文生图", "image", _load("qwen_image_t2i_API.json")
)
wf = client.get(f"/api/workflows/{wf_id}").json()
roles = _by_role(wf["input_schema"])
r = client.post(
"/api/playground/generate",
json={
"workflow_id": wf_id,
"input_values": {
roles["prompt"]["nodeId"]: "雨夜霓虹街道",
roles["seed"]["nodeId"]: 99,
roles["width"]["nodeId"]: 1024,
roles["height"]["nodeId"]: 768,
},
"source": "studio-image",
"endpoint_id": settings.comfyui_active_id,
},
)
assert r.status_code == 200, r.text
job = _wait_done(client, r.json()["job"]["id"])
assert job["status"] == "done", job.get("error")
queued = fake_comfy.prompts[-1]["prompt"]
assert queued["6"]["inputs"]["text"] == "雨夜霓虹街道"
assert queued["3"]["inputs"]["seed"] == 99
assert queued["58"]["inputs"]["width"] == 1024
assert queued["58"]["inputs"]["height"] == 768
assert queued["3"]["inputs"]["positive"] == ["6", 0]
history = client.get("/api/playground/jobs").json()
assert any(j["id"] == job["id"] and j["status"] == "done" for j in history)
def test_real_qwen21_t2i_full_chain(client, fake_comfy): # noqa: F811
with _real_run(client, fake_comfy):
wf_id = _import_workflow(
client, "真实·Qwen2.1 文生图", "image", _load("wen_sheng_tu_API.json")
)
roles = _by_role(client.get(f"/api/workflows/{wf_id}").json()["input_schema"])
r = client.post(
"/api/playground/generate",
json={
"workflow_id": wf_id,
"input_values": {
roles["prompt"]["nodeId"]: "雨夜霓虹街道",
roles["negative"]["nodeId"]: "低清晰度",
roles["seed"]["nodeId"]: 7,
},
"source": "studio-image",
"endpoint_id": settings.comfyui_active_id,
},
)
assert r.status_code == 200, r.text
job = _wait_done(client, r.json()["job"]["id"])
assert job["status"] == "done", job.get("error")
queued = fake_comfy.prompts[-1]["prompt"]
assert queued["459:452"]["inputs"]["prompt"] == "雨夜霓虹街道"
assert queued["459:452"]["inputs"]["negative_prompt"] == "低清晰度"
assert queued["459:458"]["inputs"]["seed"] == 7
assert queued["459:458"]["inputs"]["negative"] == ["459:452", 1]
def test_comfy_rejection_is_reported_in_chinese(client, fake_comfy): # noqa: F811
fake_comfy.fail_prompt = True
with _real_run(client, fake_comfy):
wf_id = _import_workflow(
client, "原生·Qwen(拒绝)", "image", _load("qwen_image_t2i_API.json")
)
r = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "input_values": {"6": "x"}, "source": "studio-image"},
)
job = _wait_done(client, r.json()["job"]["id"])
assert job["status"] == "failed"
assert "ComfyUI 拒绝执行该工作流" in job["error"]
assert "HTTP 500" in job["error"]
def test_generate_unknown_workflow_has_chinese_detail(client):
r = client.post("/api/playground/generate", json={"workflow_id": 987654, "input_values": {}})
assert r.status_code == 404
assert "工作流不存在" in r.json()["detail"]
@@ -0,0 +1,311 @@
"""HEL-636 A1–A6: the five-usage classifier judges from graph evidence only.
Every case feeds real (or minimally mutated) ComfyUI API exports — never a
file name, model name or fixed node id — and asserts the usage, the media
slots it implies, and the failure paths for ambiguous / unknown graphs.
"""
from __future__ import annotations
import json
from pathlib import Path
from calliope.comfyui.lint import blocking_problem, workflow_warnings
from calliope.comfyui.parser import (
classify_usage,
detect_untagged_inputs,
parse_dynamic_inputs,
parse_dynamic_outputs,
)
from calliope.comfyui.patcher import patch_workflow
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
EXAMPLES = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
def _load(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def _rename_everything(wf: dict, id_shift: int = 7) -> dict:
"""Change every node id, title and widget value the classifier must not
depend on (model file names / prompt texts)."""
def new_id(nid: str) -> str:
return f"x{int(nid) * id_shift + 3}" if nid.isdigit() else "x" + nid.replace(":", "_")
out: dict = {}
for nid, node in wf.items():
out[new_id(nid)] = json.loads(json.dumps(node))
for node in out.values():
for val in (node.get("inputs") or {}).values():
if isinstance(val, list) and len(val) == 2 and str(val[0]).isdigit():
val[0] = new_id(str(val[0]))
if node.get("_meta", {}).get("title"):
node["_meta"]["title"] = f"改·{node['_meta']['title']}"
for key, val in list((node.get("inputs") or {}).items()):
if isinstance(val, str) and val.endswith((".safetensors", ".gguf")):
node["inputs"][key] = "另一个模型_" + val
return out
def _media(inputs: list[dict]) -> list[dict]:
return [
i
for i in inputs
if i["kind"] in ("image", "image_list", "video", "audio")
]
# ---------------------------------------------------------------------------
# A1 / A2 / A3: the three real shapes, plus id/title/model renames
# ---------------------------------------------------------------------------
def test_a1_native_t2i_is_text2image_and_form_has_no_image_slot():
wf = _load(FIXTURES / "qwen_image_t2i_API.json")
c = classify_usage(wf)
assert c["usage"] == "text2image"
assert c["ambiguous"] is False and c["needs_mapping"] is False
assert _media(parse_dynamic_inputs(wf)) == []
outs = parse_dynamic_outputs(wf)
assert [(o["nodeId"], o["kind"]) for o in outs] == [("60", "image")]
def test_a2_qwen_edit_variant_with_new_ids_titles_and_model_stays_image2image():
wf = _rename_everything(_load(FIXTURES / "qwen_image_edit_mask_API.json"))
c = classify_usage(wf)
assert c["usage"] == "image2image"
inputs = parse_dynamic_inputs(wf)
img = [i for i in _media(inputs) if i.get("role") == "image"]
assert len(img) == 1 and img[0]["required"] is True
mask = [i for i in _media(inputs) if i.get("role") == "mask"]
assert len(mask) == 1 # mask never counts as an input image
def test_a3_i2v_variant_renamed_still_image2video_with_optional_last_frame():
wf = _rename_everything(_load(FIXTURES / "h3_i2v_04_API.json"))
c = classify_usage(wf)
assert c["usage"] == "image2video"
media = _media(parse_dynamic_inputs(wf))
# HEL-671: the FL2VA node's unwired last_frame is offered, but optional
assert [(i.get("role"), i["required"]) for i in media] == [
("first_frame", True),
("last_frame", False),
]
assert blocking_problem(wf) is None
def test_a4_ref2va_variant_with_renamed_class_and_prefix_still_groups():
wf = _rename_everything(_load(FIXTURES / "h3_ref2va_05_API.json"))
for node in wf.values():
if node.get("class_type") == "MiniMaxH3ReferenceToVideo":
node["class_type"] = "MyFancyRefToVideo"
inputs = node["inputs"]
for k in list(inputs):
if k.startswith("ref_images.ref_image_"):
inputs[k.replace("ref_images.ref_image_", "refs.pic_")] = inputs.pop(k)
c = classify_usage(wf)
assert c["usage"] == "ref2video"
groups = [i for i in detect_untagged_inputs(wf) if i["kind"] == "image_list"]
assert len(groups) == 1
g = groups[0]
assert g["role"] == "reference_image" and (g["min"], g["max"]) == (1, 9)
# patch rewires the renamed group in order and refuses a 10th image
key = g["nodeId"]
node_id = key.split("@")[0]
patched = patch_workflow(wf, {key: [f"/u/{i}.png" for i in range(4)]})
names = sorted(
(k for k in patched[node_id]["inputs"] if k.startswith("refs.pic_")),
key=lambda k: int(k.rsplit("_", 1)[-1]) if k.rsplit("_", 1)[-1].isdigit() else int(k[-1]),
)
assert names == [f"refs.pic_{i}" for i in range(4)]
from calliope.comfyui.validate import validate_input_values
_c, errs = validate_input_values(
detect_untagged_inputs(wf), {key: [f"/u/{i}.png" for i in range(10)]}
)
assert any("最多 9 张" in e for e in errs)
def test_qwen_single_numbered_encoder_input_is_not_mistaken_for_a_group():
"""TextEncodeQwenImage21's ``images.image_1`` stays an ordinary input slot."""
inputs = parse_dynamic_inputs(_load(FIXTURES / "qwen21_edit_sage_API.json"))
assert not any(i["kind"] == "image_list" for i in inputs)
assert any(i.get("role") == "image" for i in inputs)
# ---------------------------------------------------------------------------
# Judgment table rows from the real example library
# ---------------------------------------------------------------------------
def test_all_real_exports_classify():
expect = {
"qwen_image_t2i_API.json": "text2image",
"qwen_image_edit_mask_API.json": "image2image",
"qwen21_edit_sage_API.json": "image2image",
"h3_i2v_04_API.json": "image2video",
"h3_ref2va_05_API.json": "ref2video",
"minimax_h3_t2v_untagged_API.json": "text2video",
"wen_sheng_tu_API.json": "text2image",
"wen_sheng_shipin_API.json": "text2video",
}
for name, usage in expect.items():
assert classify_usage(_load(FIXTURES / name))["usage"] == usage, name
examples = {
"Krea2_t2i_20260818_API.json": "text2image",
"krea2_T2I_CharSheetV1_API.json": "text2image",
"minimax_h3-Turbo_Text2Video_20260813_API.json": "text2video",
"minimax_h3-Turbo_Image2Video_20260813_API.json": "text2video",
"MiniMax-H3-Turbo-R2V-Extend-2Refs1Vid_API.json": "ref2video",
"video_minimax_h3_r2v_2ref_API.json": "ref2video",
"video_minimax_h3_r2v_3ref_API.json": "ref2video",
"video_minimax_h3_r2v_4ref_API.json": "ref2video",
"video_minimax_h3_r2v_5ref_API.json": "ref2video",
}
for name, usage in examples.items():
assert classify_usage(_load(EXAMPLES / name))["usage"] == usage, name
def test_r2v_extend_reports_its_video_input_slot():
wf = _load(EXAMPLES / "MiniMax-H3-Turbo-R2V-Extend-2Refs1Vid_API.json")
c = classify_usage(wf)
assert c["usage"] == "ref2video"
assert c["evidence"]["video_slots"], "the reference-video slot must be reported"
assert any("视频输入槽" in line for line in c["evidence"]["summary"])
# ---------------------------------------------------------------------------
# A5: ambiguity — single loose image into a video graph; multi-output graph
# ---------------------------------------------------------------------------
def _load_image(nid: str) -> dict:
return {
"class_type": "LoadImage",
"inputs": {"image": "x.png"},
"_meta": {"title": f"图 {nid}"},
}
def test_single_loose_image_with_video_output_is_ambiguous():
wf = {
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
"5": {"class_type": "LoadImage", "inputs": {"image": "x.png"}},
"7": {
"class_type": "SomeVideoSampler",
"inputs": {"prompt": ["1", 0], "image": ["5", 0]},
},
"9": {"class_type": "SaveVideo", "inputs": {"images": ["7", 0]}},
}
c = classify_usage(wf)
assert c["usage"] is None and c["ambiguous"] is True
assert "首帧还是参考图" in c["problem"]
def test_first_frame_role_decides_image2video_without_ambiguity():
wf = {
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
"5": {"class_type": "LoadImage", "inputs": {"image": "x.png"}},
"7": {
"class_type": "SomeVideoSampler",
"inputs": {"prompt": ["1", 0], "first_frame": ["5", 0]},
},
"9": {"class_type": "SaveVideo", "inputs": {"images": ["7", 0]}},
}
assert classify_usage(wf)["usage"] == "image2video"
def test_same_graph_both_image_and_video_outputs_is_ambiguous():
wf = {
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
"5": {"class_type": "LoadImage", "inputs": {"image": "x.png"}},
"7": {
"class_type": "SomeVideoSampler",
"inputs": {"prompt": ["1", 0], "image": ["5", 0]},
},
"9": {"class_type": "SaveVideo", "inputs": {"images": ["7", 0]}},
"10": {"class_type": "SaveImage", "inputs": {"images": ["7", 1]}},
}
c = classify_usage(wf)
assert c["usage"] is None and c["ambiguous"] is True
assert "同时输出图片和视频" in c["problem"]
def test_two_reference_images_into_a_custom_node_become_a_group_ref2video():
wf = {
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
"5": {"class_type": "LoadImage", "inputs": {"image": "a.png"}},
"6": {"class_type": "LoadImage", "inputs": {"image": "b.png"}},
"7": {
"class_type": "GenericRefToVideo",
"inputs": {
"prompt": ["1", 0],
"refs.pic_1": ["5", 0],
"refs.pic_2": ["6", 0],
},
},
"9": {"class_type": "SaveVideo", "inputs": {"images": ["7", 0]}},
}
c = classify_usage(wf)
assert c["usage"] == "ref2video"
groups = [i for i in detect_untagged_inputs(wf) if i["kind"] == "image_list"]
assert [g["nodeId"] for g in groups] == ["7@refs"]
# ---------------------------------------------------------------------------
# A6: failure paths — no output, unknown custom output nodes
# ---------------------------------------------------------------------------
def test_no_output_graph_reports_chinese_error_and_blocks():
wf = {
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "p"}},
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["1", 0]}},
}
c = classify_usage(wf)
assert c["usage"] is None and c["ambiguous"] is False
assert "未在图中找到输出节点" in c["problem"]
assert blocking_problem(wf) and "未在图中找到输出节点" in blocking_problem(wf)
def test_unknown_custom_save_node_needs_mapping_and_keeps_a_runnable_path():
wf = {
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
"7": {
"class_type": "MyCloudExporter",
"inputs": {"filename_prefix": "out", "render": ["8", 0]},
},
"8": {"class_type": "VAEDecode", "inputs": {"samples": ["1", 0]}},
}
c = classify_usage(wf)
assert c["needs_mapping"] is True
assert c["usage"] is None and "无法识别" in (c["problem"] or "")
# unknown savers are a warning (user maps them at import), not a block —
# once the user confirms the usage the job can still run and results are
# pulled from ComfyUI history's images/gifs/videos keys.
codes = {(w["level"], w["code"]) for w in workflow_warnings(wf)}
assert ("warning", "unknown_output_node") in codes
assert ("error", "no_output") not in codes
def test_utility_sink_tail_is_not_an_output_and_does_not_break_detection():
"""VideoCombine feeding a cache-cleanup tail still counts as the output."""
wf = _load(FIXTURES / "minimax_h3_t2v_untagged_API.json")
wf["9999"] = {
"class_type": "easy cleanGpuUsed",
"inputs": {"anything": ["5978", 0]},
}
c = classify_usage(wf)
assert c["usage"] == "text2video"
assert c["needs_mapping"] is False
assert workflow_warnings(wf) == []
def test_evidence_lists_output_nodes_and_slots():
wf = _load(FIXTURES / "h3_ref2va_05_API.json")
c = classify_usage(wf)
ev = c["evidence"]
assert any(o["nodeId"] == "92" and o["kind"] == "video" for o in ev["outputs"])
assert any(s["kind"] == "image_list" for s in ev["image_slots"])
assert any("输出" in line for line in ev["summary"])
+28 -4
View File
@@ -26,6 +26,15 @@ VIDEO_WORKFLOW = {
"inputs": {"file": ""},
"_meta": {"title": "Previous Clip (Input:video)"},
},
"30": {
"class_type": "VHS_VideoCombine",
"inputs": {"images": ["40", 0]},
"_meta": {"title": "Video Output (Output:video)"},
},
"40": {
"class_type": "VAEDecode",
"inputs": {"samples": ["10", 0]},
},
}
# Same but the video slot is missing entirely — must be rejected at enqueue.
@@ -35,6 +44,15 @@ NO_VIDEO_WORKFLOW = {
"inputs": {"text": ""},
"_meta": {"title": "Main Prompt (Input:prompt)"},
},
"30": {
"class_type": "VHS_VideoCombine",
"inputs": {"images": ["40", 0]},
"_meta": {"title": "Video Output (Output:video)"},
},
"40": {
"class_type": "VAEDecode",
"inputs": {"samples": ["10", 0]},
},
}
@@ -250,12 +268,14 @@ def _scene_ids(pid: int) -> list[int]:
async def _fake_history(_client, _prompt_id, *_args):
"""A successful ComfyUI history with one video output named out.mp4.
Extra positional args (job_id) are accepted for the cancel-check wiring."""
"""A successful ComfyUI history with one video output named out.mp4,
reported under the workflow's real output node (30 = the tagged
VHS_VideoCombine). Extra positional args (job_id) are accepted for the
cancel-check wiring."""
return {
"status": {"status_str": "success", "completed": True},
"outputs": {
"99": {"videos": [{"filename": "out.mp4", "subfolder": "", "type": "output"}]}
"30": {"videos": [{"filename": "out.mp4", "subfolder": "", "type": "output"}]}
},
}
@@ -269,7 +289,11 @@ def _stub_comfy(monkeypatch, captured: dict) -> list[str]:
prepare_media_inputs handed it; download_image writes out.mp4. Returns
the list that collects uploaded local paths.
"""
queue_manager.paused = True
# monkeypatch (not bare assignment): queue_manager is a process-level
# singleton — a bare `paused = True` leaks into every later test and
# stalls e.g. test_workbench's worker (HEL-619). monkeypatch restores
# the previous value at teardown.
monkeypatch.setattr(queue_manager, "paused", True)
uploads: list[str] = []
async def fake_upload_video(self, path, subfolder="calliope"):
+454
View File
@@ -0,0 +1,454 @@
"""五段创作工作台 (HEL-614) — stage lifecycle, context handoff (confirmed
drafts only), edit/confirm/restore, script import, in-flow generation via
the shared queue, and honest failure states. LLM is faked by monkeypatching
the workbench router's LLMClient; ComfyUI runs in dry_run placeholder mode."""
from __future__ import annotations
import json
import time
import httpx
import pytest
from calliope.config import settings
from calliope.routers import workbench as wb
IMAGE_WORKFLOW = {
"1": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "默认提示词"},
"_meta": {"title": "(Input:prompt) 正向提示词"},
},
"9": {
"class_type": "SaveImage",
"inputs": {"images": "x"},
"_meta": {"title": "(Output:image) 图片输出"},
},
}
SCRIPT_TEXT = (
"《夜行便利店》\n第一场·内景·便利店·凌晨两点:店员小夏整理货架,风铃轻响,"
"一位穿米色风衣的女孩推门而入,径直走到关东煮前。\n小夏:还是老样子?\n女孩点头。"
)
STYLE_REPLY = json.dumps(
{
"directions": [
{
"id": "style1",
"name": "霓虹赛博",
"description": "青紫霓虹、湿润反光、浅景深",
"prompt": "cyberpunk convenience store, neon rim light, wet floor reflections",
"negative_prompt": "blurry, lowres",
},
{
"id": "style2",
"name": "暖调生活流",
"description": "暖黄顶光、柔焦、生活质感",
"prompt": "warm cozy interior, soft rim light, film grain",
"negative_prompt": "",
},
]
},
ensure_ascii=False,
)
STORYBOARD_REPLY = json.dumps(
{
"shots": [
{
"id": "shot1",
"scene": "1",
"shot_size": "全景",
"camera": "固定",
"description": "凌晨便利店全貌,货架整齐,灯光惨白",
"dialog": "",
"duration_sec": 3,
},
{
"id": "shot2",
"scene": "1",
"shot_size": "特写",
"camera": "推",
"description": "风铃轻响,女孩的手推开玻璃门",
"dialog": "小夏:还是老样子?",
"duration_sec": 2,
},
]
},
ensure_ascii=False,
)
ASSETS_REPLY = json.dumps(
{
"assets": [
{
"id": "asset1",
"kind": "character",
"name": "小夏",
"description": "夜班店员,白色制服",
"prompt": "convenience store clerk, white uniform, name tag, half body",
},
{
"id": "asset2",
"kind": "location",
"name": "便利店",
"description": "深夜便利店内部",
"prompt": "japanese convenience store interior at night, bright shelves",
},
]
},
ensure_ascii=False,
)
SHOT_REPLY = json.dumps(
{
"shots": [
{
"id": "vshot1",
"shot_no": "S1",
"description": "开场全景",
"prompt": "slow push in on convenience store shelves at night, cinematic",
"duration_sec": 4,
},
{
"id": "vshot2",
"shot_no": "S2",
"description": "风铃特写",
"prompt": "close up wind chime, door opens, warm light spill",
"duration_sec": 3,
},
]
},
ensure_ascii=False,
)
class FakeLLM:
"""Scripted replies; records every chat() call for context assertions."""
replies: list[str] = []
calls: list[list[dict]] = []
@classmethod
def reset(cls, replies: list[str]) -> None:
cls.replies = list(replies)
cls.calls = []
@classmethod
def for_role(cls, role: str, *, timeout: float = 120.0) -> "FakeLLM":
return cls()
async def chat(self, messages, temperature=0.7, **kwargs) -> str:
type(self).calls.append(messages)
if type(self).replies:
return type(self).replies.pop(0)
return json.dumps({"text": SCRIPT_TEXT}, ensure_ascii=False)
async def close(self) -> None:
return None
class RefusingLLM(FakeLLM):
@classmethod
def for_role(cls, role: str, *, timeout: float = 120.0) -> "RefusingLLM":
return cls()
async def chat(self, messages, temperature=0.7, **kwargs) -> str:
type(self).calls.append(messages)
raise httpx.ConnectError("connection refused")
def _make_project(client, **fields) -> int:
payload = {"title": fields.get("title", "夜行便利店"), "idea": "凌晨便利店的温暖相遇"}
r = client.post("/api/projects", json=payload)
assert r.status_code == 200, r.text
return r.json()["id"]
def _overview(client, pid: int) -> dict:
r = client.get(f"/api/workbench/projects/{pid}")
assert r.status_code == 200, r.text
return r.json()
def _stage(overview: dict, key: str) -> dict:
return next(s for s in overview["stages"] if s["stage_key"] == key)
def _wait_stage_status(client, pid: int, key: str, statuses: set[str], timeout: float = 10.0) -> dict:
deadline = time.time() + timeout
stage = None
while time.time() < deadline:
stage = _stage(_overview(client, pid), key)
if stage["status"] in statuses:
return stage
time.sleep(0.2)
pytest.fail(f"stage {key} stuck in {stage and stage['status']!r}, want {statuses}")
def _run(client, pid: int, key: str, instruction: str | None = None):
body = {"instruction": instruction} if instruction else {}
return client.post(f"/api/workbench/projects/{pid}/stages/{key}/run", json=body)
def _confirm(client, pid: int, key: str, **extra):
return client.post(f"/api/workbench/projects/{pid}/stages/{key}/confirm", json=extra or None)
def test_assistant_notes_keep_confirmation_and_survive_artifact_edits(client):
pid = _make_project(client)
note_url = f"/api/workbench/projects/{pid}/stages/style/note"
r = client.patch(note_url, json={"text": "建议先比较冷暖两个方向。"})
assert r.status_code == 200
assert r.json()["stage"]["status"] == "todo"
assert r.json()["stage"]["assistant_note"] == "建议先比较冷暖两个方向。"
assert _confirm(client, pid, "style").status_code == 400
# An ordinary structured save must keep the separate advice.
r = client.patch(f"/api/workbench/projects/{pid}/stages/style", json={"content": json.loads(STYLE_REPLY)})
assert r.status_code == 200
assert r.json()["stage"]["assistant_note"] == "建议先比较冷暖两个方向。"
# Notes on confirmed content don't change its status or confirmed snapshot.
client.post(f"/api/workbench/projects/{pid}/stages/script/import", json={"text": SCRIPT_TEXT})
before = _stage(_overview(client, pid), "script")
r = client.patch(f"/api/workbench/projects/{pid}/stages/script/note", json={"text": "下一版建议加强人物动机。"})
assert r.status_code == 200
after = r.json()["stage"]
assert after["status"] == before["status"] == "confirmed"
assert after["confirmed_content"] == before["confirmed_content"]
assert after["draft_edited"] == before["draft_edited"]
assert _confirm(client, pid, "script").status_code == 200
confirmed = _stage(_overview(client, pid), "script")
assert "assistant_note" not in confirmed["confirmed_content"]
client.patch(f"/api/workbench/projects/{pid}/stages/script/note", json={"text": "保留确认后的新建议。"})
restored = client.post(f"/api/workbench/projects/{pid}/stages/script/restore").json()["stage"]
assert restored["assistant_note"] == "保留确认后的新建议。"
assert restored["confirmed_content"] == confirmed["confirmed_content"]
assert client.patch(note_url, json={"text": ""}).json()["stage"]["assistant_note"] == ""
assert client.patch(note_url, json={"text": "x" * 200_001}).status_code == 422
assert client.patch(f"/api/workbench/projects/{pid}/stages/unknown/note", json={"text": "x"}).status_code == 404
# ─────────────────────────────────────────────────────────────────────────
def test_overview_ensures_five_todo_stages(client):
pid = _make_project(client)
overview = _overview(client, pid)
assert [s["stage_key"] for s in overview["stages"]] == [
"script",
"style",
"storyboard",
"assets",
"shot",
]
assert all(s["status"] == "todo" for s in overview["stages"])
assert overview["project"]["title"] == "夜行便利店"
def test_run_requires_previous_stage_confirmed(client, monkeypatch):
monkeypatch.setattr(wb, "LLMClient", FakeLLM)
FakeLLM.reset([])
pid = _make_project(client)
r = _run(client, pid, "style")
assert r.status_code == 409
assert "剧本创作" in r.json()["detail"]
def test_five_stage_flow_confirmed_context_handoff(client, monkeypatch):
"""从创意起步端到端:每段生成→确认;只有已确认内容进入下一段上下文。"""
monkeypatch.setattr(wb, "LLMClient", FakeLLM)
FakeLLM.reset(
[
json.dumps({"text": SCRIPT_TEXT}, ensure_ascii=False),
STYLE_REPLY,
STORYBOARD_REPLY,
ASSETS_REPLY,
SHOT_REPLY,
]
)
pid = _make_project(client)
# 1. 剧本创作
assert _run(client, pid, "script").status_code == 200
stage = _wait_stage_status(client, pid, "script", {"awaiting_confirm"})
assert stage["content"]["text"] == SCRIPT_TEXT
assert _confirm(client, pid, "script").status_code == 200
# 2. 视觉设计 — 上下文带剧本
assert _run(client, pid, "style").status_code == 200
stage = _wait_stage_status(client, pid, "style", {"awaiting_confirm"})
assert len(stage["content"]["directions"]) == 2
assert SCRIPT_TEXT[:10] in FakeLLM.calls[-1][1]["content"]
r = _confirm(client, pid, "style", chosen_style_id="style2")
assert r.status_code == 200
assert r.json()["stage"]["chosen_style_id"] == "style2"
# 3. 分镜设计 — 上下文带剧本 + 已选风格(暖调生活流,非默认第一套)
assert _run(client, pid, "storyboard").status_code == 200
_wait_stage_status(client, pid, "storyboard", {"awaiting_confirm"})
ctx = FakeLLM.calls[-1][1]["content"]
assert "暖调生活流" in ctx
assert SCRIPT_TEXT[:10] in ctx
assert _confirm(client, pid, "storyboard").status_code == 200
# 4. 素材资产 — 上下文带分镜
assert _run(client, pid, "assets").status_code == 200
_wait_stage_status(client, pid, "assets", {"awaiting_confirm"})
ctx = FakeLLM.calls[-1][1]["content"]
assert "风铃轻响" in ctx
assert _confirm(client, pid, "assets").status_code == 200
# 5. 镜头脚本 — 上下文带素材清单
assert _run(client, pid, "shot").status_code == 200
stage = _wait_stage_status(client, pid, "shot", {"awaiting_confirm"})
ctx = FakeLLM.calls[-1][1]["content"]
assert "小夏" in ctx
assert stage["content"]["shots"][0]["prompt"].startswith("slow push")
# 进度反映到项目列表(真实阶段数据)
r = client.get("/api/projects")
stats = next(p for p in r.json() if p["id"] == pid)["stats"]
assert stats["stage_confirmed"] == 4
assert stats["stage_status"]["shot"] == "awaiting_confirm"
def test_run_failure_keeps_confirmed_snapshot_and_reads_chinese(client, monkeypatch):
monkeypatch.setattr(wb, "LLMClient", RefusingLLM)
pid = _make_project(client)
# 先用正常 LLM 确认剧本
monkeypatch.setattr(wb, "LLMClient", FakeLLM)
FakeLLM.reset([json.dumps({"text": SCRIPT_TEXT}, ensure_ascii=False)])
_run(client, pid, "script")
_wait_stage_status(client, pid, "script", {"awaiting_confirm"})
_confirm(client, pid, "script")
# 视觉设计生成失败(连接拒绝)
monkeypatch.setattr(wb, "LLMClient", RefusingLLM)
assert _run(client, pid, "style").status_code == 200
stage = _wait_stage_status(client, pid, "style", {"todo"})
assert "连接大模型服务失败" in (stage["last_run"]["error"] or "")
# 剧本阶段不受影响
assert _stage(_overview(client, pid), "script")["status"] == "confirmed"
def test_edit_after_confirm_and_restore(client, monkeypatch):
monkeypatch.setattr(wb, "LLMClient", FakeLLM)
FakeLLM.reset([json.dumps({"text": SCRIPT_TEXT}, ensure_ascii=False)])
pid = _make_project(client)
_run(client, pid, "script")
_wait_stage_status(client, pid, "script", {"awaiting_confirm"})
_confirm(client, pid, "script")
# 编辑已确认阶段 → 变回待确认草稿,已确认快照保留
edited = {"text": SCRIPT_TEXT + "\n(结尾新增:女孩微笑离开。)"}
r = client.patch(f"/api/workbench/projects/{pid}/stages/script", json={"content": edited})
assert r.status_code == 200
stage = r.json()["stage"]
assert stage["status"] == "awaiting_confirm"
assert stage["draft_edited"] is True
assert stage["confirmed_content"]["text"] == SCRIPT_TEXT
# 恢复已确认版本
r = client.post(f"/api/workbench/projects/{pid}/stages/script/restore")
assert r.status_code == 200
stage = r.json()["stage"]
assert stage["status"] == "confirmed"
assert stage["content"]["text"] == SCRIPT_TEXT
def test_import_script_confirms_and_unlocks_style(client, monkeypatch):
monkeypatch.setattr(wb, "LLMClient", FakeLLM)
FakeLLM.reset([STYLE_REPLY])
pid = _make_project(client)
r = client.post(f"/api/workbench/projects/{pid}/stages/script/import", json={"text": SCRIPT_TEXT})
assert r.status_code == 200
assert r.json()["stage"]["status"] == "confirmed"
# 跳过剧本创作,直接从视觉设计继续
assert _run(client, pid, "style").status_code == 200
_wait_stage_status(client, pid, "style", {"awaiting_confirm"})
# 太短的导入被拒绝
r = client.post(
f"/api/workbench/projects/{pid}/stages/script/import", json={"text": "太短"}
)
assert r.status_code == 400
assert "太短" in r.json()["detail"]
def test_generate_without_workflow_is_chinese_400(client):
pid = _make_project(client)
r = client.post(
f"/api/workbench/projects/{pid}/generate",
json={"kind": "image", "stage_key": "assets", "target_id": "asset1", "prompt": "a cat"},
)
assert r.status_code == 400
assert "没有可用的生图工作流" in r.json()["detail"]
def test_generate_image_job_returns_to_project(client):
pid = _make_project(client)
# 手动放入素材阶段草稿(带 asset1)
r = client.patch(
f"/api/workbench/projects/{pid}/stages/assets",
json={"content": json.loads(ASSETS_REPLY)},
)
assert r.status_code == 200
wf = client.post(
"/api/workflows",
json={"name": "假·文生图", "kind": "image", "workflow_json": IMAGE_WORKFLOW},
)
assert wf.status_code == 200, wf.text
r = client.post(
f"/api/workbench/projects/{pid}/generate",
json={
"kind": "image",
"stage_key": "assets",
"target_id": "asset1",
"prompt": "convenience store clerk portrait",
},
)
assert r.status_code == 200, r.text
job = r.json()["job"]
assert job["payload"]["source"] == "workbench"
assert job["payload"]["target_id"] == "asset1"
assert job["payload"]["input_values"]["1"] == "convenience store clerk portrait"
# dry_run worker completes with a placeholder; overview enriches the asset
deadline = time.time() + 40
job_done = False
mine = None
while time.time() < deadline:
jobs = client.get(f"/api/jobs?project_id={pid}").json()
mine = next((j for j in jobs if j["id"] == job["id"]), None)
if mine and mine["status"] in ("done", "failed"):
job_done = mine["status"] == "done"
break
time.sleep(0.4)
assert job_done, f"workbench job did not finish: {mine}"
stage = _stage(_overview(client, pid), "assets")
asset = next(a for a in stage["content"]["assets"] if a["id"] == "asset1")
assert asset["job"]["status"] == "done"
assert asset["job"]["output"] and asset["job"]["output"].endswith(".png")
def test_project_list_stats_include_active_jobs(client):
pid = _make_project(client)
wf = client.post(
"/api/workflows",
json={"name": "假·文生图2", "kind": "image", "workflow_json": IMAGE_WORKFLOW},
)
wf_id = wf.json()["id"]
r = client.post(
f"/api/workbench/projects/{pid}/generate",
json={"kind": "image", "stage_key": "assets", "prompt": "x"},
)
assert r.status_code == 200
r = client.get("/api/projects")
stats = next(p for p in r.json() if p["id"] == pid)["stats"]
assert stats["active_jobs"] >= 1
assert stats["stage_confirmed"] == 0
# a project that never opened the workbench reports no stage rows at all
pid2 = _make_project(client, title="未开工")
stats2 = next(p for p in client.get("/api/projects").json() if p["id"] == pid2)["stats"]
assert stats2["stage_status"] == {} and stats2["stage_confirmed"] == 0
@@ -0,0 +1,421 @@
"""HEL-681 (S2): batch import endpoints, meta list view, migration safety.
Everything runs on a temp DB via the shared client fixture — the real
146-file library is never touched, matching the card's「不导入生产数据」boundary.
"""
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
from calliope.db import migrate_db
import pytest
EXAMPLES = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
def _ui_graph():
"""Minimal clean t2i UI graph (same shape as test_ui_convert's)."""
return {
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"mode": 0,
"inputs": [{"name": "ckpt_name", "type": "COMBO", "widget": {"name": "ckpt_name"}, "link": None}],
"outputs": [
{"name": "MODEL", "type": "MODEL", "links": [3], "slot_index": 0},
{"name": "CLIP", "type": "CLIP", "links": [1, 2], "slot_index": 1},
{"name": "VAE", "type": "VAE", "links": [8], "slot_index": 2},
],
"widgets_values": ["qwen_image_2.safetensors"],
},
{
"id": 2,
"type": "CLIPTextEncode",
"mode": 0,
"inputs": [
{"name": "clip", "type": "CLIP", "link": 1},
{"name": "text", "type": "STRING", "widget": {"name": "text"}, "link": None},
],
"outputs": [{"name": "CONDITIONING", "type": "CONDITIONING", "links": [4], "slot_index": 0}],
"widgets_values": ["a cat"],
},
{
"id": 3,
"type": "CLIPTextEncode",
"mode": 0,
"inputs": [
{"name": "clip", "type": "CLIP", "link": 2},
{"name": "text", "type": "STRING", "widget": {"name": "text"}, "link": None},
],
"outputs": [{"name": "CONDITIONING", "type": "CONDITIONING", "links": [5], "slot_index": 0}],
"widgets_values": ["bad"],
},
{
"id": 4,
"type": "EmptyLatentImage",
"mode": 0,
"inputs": [
{"name": "width", "type": "INT", "widget": {"name": "width"}, "link": None},
{"name": "height", "type": "INT", "widget": {"name": "height"}, "link": None},
{"name": "batch_size", "type": "INT", "widget": {"name": "batch_size"}, "link": None},
],
"outputs": [{"name": "LATENT", "type": "LATENT", "links": [6], "slot_index": 0}],
"widgets_values": [512, 512, 1],
},
{
"id": 5,
"type": "KSampler",
"mode": 0,
"inputs": [
{"name": "model", "type": "MODEL", "link": 3},
{"name": "positive", "type": "CONDITIONING", "link": 4},
{"name": "negative", "type": "CONDITIONING", "link": 5},
{"name": "latent_image", "type": "LATENT", "link": 6},
{"name": "seed", "type": "INT", "widget": {"name": "seed"}, "link": None},
{"name": "steps", "type": "INT", "widget": {"name": "steps"}, "link": None},
{"name": "cfg", "type": "FLOAT", "widget": {"name": "cfg"}, "link": None},
{"name": "sampler_name", "type": "COMBO", "widget": {"name": "sampler_name"}, "link": None},
{"name": "scheduler", "type": "COMBO", "widget": {"name": "scheduler"}, "link": None},
{"name": "denoise", "type": "FLOAT", "widget": {"name": "denoise"}, "link": None},
],
"outputs": [{"name": "LATENT", "type": "LATENT", "links": [7], "slot_index": 0}],
"widgets_values": [42, "fixed", 20, 7, "euler", "normal", 1.0],
},
{
"id": 6,
"type": "VAEDecode",
"mode": 0,
"inputs": [
{"name": "samples", "type": "LATENT", "link": 7},
{"name": "vae", "type": "VAE", "link": 8},
],
"outputs": [{"name": "IMAGE", "type": "IMAGE", "links": [9], "slot_index": 0}],
},
{
"id": 7,
"type": "SaveImage",
"mode": 0,
"inputs": [
{"name": "images", "type": "IMAGE", "link": 9},
{"name": "filename_prefix", "type": "STRING", "widget": {"name": "filename_prefix"}, "link": None},
],
"widgets_values": ["test"],
},
],
"links": [
[1, 1, 1, 2, 0, "CLIP"],
[2, 1, 1, 3, 0, "CLIP"],
[3, 1, 0, 5, 0, "MODEL"],
[4, 2, 0, 5, 1, "CONDITIONING"],
[5, 3, 0, 5, 2, "CONDITIONING"],
[6, 4, 0, 5, 3, "LATENT"],
[7, 5, 0, 6, 0, "LATENT"],
[8, 1, 2, 6, 1, "VAE"],
[9, 6, 0, 7, 0, "IMAGE"],
],
}
def _api_workflow():
return json.loads(
(EXAMPLES / "video_minimax_h3_r2v_2ref_API.json").read_text(encoding="utf-8")
)
def test_convert_dry_run_reports_and_stores_nothing(client):
payload = {
"files": [
{
"source_path": "3-图像生成/Qwen-Image-2.1/test_t2i.json",
"workflow_json": _ui_graph(),
}
],
"include_workflows": True,
}
resp = client.post("/api/workflows/convert", json=payload)
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 1 and body["ok"] == 1 and body["importable"] == 1
entry = body["files"][0]
assert entry["name"] == "test_t2i"
assert entry["workflow_json"]["5"]["inputs"]["seed"] == 42
assert entry["suggested"]["usage"] == "text2image"
assert entry["suggested"]["category"] == "image"
assert entry["suggested"]["model_family"] == "Qwen"
assert entry["suggested"]["media_out"] == ["image"]
# dry-run must not write anything
assert client.get("/api/workflows").json() == []
def test_convert_needs_confirmation_is_flagged_not_runnable(client):
graph = _ui_graph()
graph["nodes"].append(
{
"id": 10,
"type": "MysteryCustomNode",
"mode": 0,
"inputs": [],
"outputs": [{"name": "IMAGE", "type": "IMAGE", "links": [30], "slot_index": 0}],
"widgets_values": [1, 2, 3],
}
)
graph["nodes"].append(
{
"id": 11,
"type": "PreviewImage",
"mode": 0,
"inputs": [{"name": "images", "type": "IMAGE", "link": 30}],
"widgets_values": ["p"],
}
)
graph["links"].append([30, 10, 0, 11, 0, "IMAGE"])
resp = client.post(
"/api/workflows/convert",
json={"files": [{"source_path": "x/mystery.json", "workflow_json": graph}]},
)
body = resp.json()
assert body["needs_confirmation"] == 1
assert body["importable"] == 0
assert "需人工确认" in body["files"][0]["skip_reason"]
def test_import_batch_then_rerun_is_idempotent(client):
files = [
{
"source_path": "3-图像生成/Qwen-Image-2.1/test_t2i.json",
"workflow_json": _ui_graph(),
}
]
first = client.post("/api/workflows/import-batch", json={"files": files})
assert first.status_code == 200
body = first.json()
assert body["imported"] == 1 and body["skipped"] == 0
row_id = body["imported_ids"][0]
# second run with identical file: conflict skip, no duplicates
second = client.post("/api/workflows/import-batch", json={"files": files})
body2 = second.json()
assert body2["imported"] == 0 and body2["skipped"] == 1
assert body2["skipped_files"][0]["reason"] == "conflict"
listing = client.get("/api/workflows").json()
assert len(listing) == 1
detail = client.get(f"/api/workflows/{row_id}").json()
assert detail["usage"] == "text2image"
assert detail["is_enabled"] is True
assert detail["category"] == "image"
assert detail["model_family"] == "Qwen"
assert detail["media_io"] == {"in": [], "out": ["image"]}
assert detail["source"] == "ui_convert"
assert detail["source_path"] == "3-图像生成/Qwen-Image-2.1/test_t2i.json"
def test_import_batch_skips_needs_confirmation_files(client):
graph = _ui_graph()
graph["nodes"].append(
{
"id": 10,
"type": "MysteryCustomNode",
"mode": 0,
"inputs": [],
"outputs": [{"name": "IMAGE", "type": "IMAGE", "links": [30], "slot_index": 0}],
"widgets_values": [1, 2, 3],
}
)
graph["nodes"].append(
{
"id": 11,
"type": "PreviewImage",
"mode": 0,
"inputs": [{"name": "images", "type": "IMAGE", "link": 30}],
"widgets_values": ["p"],
}
)
graph["links"].append([30, 10, 0, 11, 0, "IMAGE"])
resp = client.post(
"/api/workflows/import-batch",
json={"files": [{"name": "mystery", "workflow_json": graph}]},
)
body = resp.json()
assert body["imported"] == 0
assert "需人工确认" in body["skipped_files"][0]["reason"]
assert client.get("/api/workflows").json() == []
def test_warn_sample_imports_with_human_confirmation(client):
"""A warn file (muted branch → input dropped) still imports when the
wizard supplies the settled usage — the manual-correction path."""
graph = _ui_graph()
graph["nodes"][2]["mode"] = 2 # mute the negative prompt branch
dry = client.post(
"/api/workflows/convert",
json={"files": [{"name": "warned", "workflow_json": graph}]},
).json()
assert dry["warn"] == 1 and dry["importable"] == 1
resp = client.post(
"/api/workflows/import-batch",
json={"files": [{"name": "warned", "workflow_json": graph, "usage": "text2image"}]},
)
body = resp.json()
assert body["imported"] == 1
detail = client.get(f"/api/workflows/{body['imported_ids'][0]}").json()
assert detail["is_enabled"] is True
assert "negative" not in detail["workflow_json"]["5"]["inputs"]
@pytest.mark.skipif(not EXAMPLES.is_dir(), reason="example library missing")
def test_import_scan_dry_run_over_repo_library(client):
resp = client.post(
"/api/workflows/import-scan",
json={"root": str(EXAMPLES), "dry_run": True},
)
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 146
assert body["fail"] == 0
assert body["ok"] + body["warn"] == 146
assert body["needs_confirmation"] > 0 # the 3 uncensored files stay honest
assert body["importable"] < body["total"]
# a dry scan must leave the DB untouched
assert client.get("/api/workflows").json() == []
def test_import_scan_commit_from_tmp_directory(client, tmp_path):
(tmp_path / "sub").mkdir()
(tmp_path / "sub" / "demo.json").write_text(json.dumps(_ui_graph()), encoding="utf-8")
(tmp_path / "plain_api.json").write_text(json.dumps(_api_workflow()), encoding="utf-8")
(tmp_path / "not_a_workflow.json").write_text(json.dumps({"hello": 1}), encoding="utf-8")
resp = client.post(
"/api/workflows/import-scan",
json={"root": str(tmp_path), "dry_run": False},
)
body = resp.json()
# only the one UI file went through this pipeline; API-format and junk skipped
assert body["imported"] == 1
listing = client.get("/api/workflows").json()
assert len(listing) == 1
assert listing[0]["source_path"] == "sub/demo.json"
# rerun: idempotent
again = client.post(
"/api/workflows/import-scan",
json={"root": str(tmp_path), "dry_run": False},
).json()
assert again["imported"] == 0
assert len(client.get("/api/workflows").json()) == 1
def test_meta_view_has_metadata_but_no_full_json(client):
client.post(
"/api/workflows/import-batch",
json={
"files": [
{
"source_path": "3-图像生成/Qwen-Image-2.1/test_t2i.json",
"workflow_json": _ui_graph(),
"tags": ["极速"],
}
]
},
)
meta = client.get("/api/workflows", params={"view": "meta"}).json()
assert len(meta) == 1
row = meta[0]
assert "workflow_json" not in row
assert "input_schema" not in row
assert "output_schema" not in row
assert row["category"] == "image"
assert row["model_family"] == "Qwen"
assert row["media_io"] == {"in": [], "out": ["image"]}
assert row["tags"] == ["极速"]
assert row["favorite"] is False
assert row["needs_mapping"] is False
def test_legacy_list_shape_and_old_records_unchanged(client):
"""Old imports (API format via POST /api/workflows) keep working; the
legacy list still carries workflow_json and the new columns default."""
created = client.post(
"/api/workflows",
json={"name": "旧导入", "workflow_json": _api_workflow()},
)
assert created.status_code == 200
legacy = client.get("/api/workflows").json()
assert len(legacy) == 1
row = legacy[0]
assert isinstance(row["workflow_json"], dict)
assert "usage" in row and "output_map" in row
assert row["favorite"] is False
assert row["tags"] == []
assert row["media_io"] == {"in": [], "out": []}
meta = client.get("/api/workflows", params={"view": "meta"}).json()
assert meta[0]["needs_mapping"] is False or meta[0]["needs_mapping"] is True
assert meta[0]["source"] is None
def test_favorite_tags_patch_and_touch(client):
wid = client.post(
"/api/workflows/import-batch",
json={"files": [{"name": "fav", "workflow_json": _ui_graph()}]},
).json()["imported_ids"][0]
resp = client.patch(f"/api/workflows/{wid}", json={"favorite": True, "tags": ["高质量"]})
assert resp.status_code == 200
assert resp.json()["favorite"] is True
assert resp.json()["tags"] == ["高质量"]
touch = client.post(f"/api/workflows/{wid}/touch")
assert touch.status_code == 200
meta = client.get("/api/workflows", params={"view": "meta"}).json()
assert meta[0]["last_used_at"] is not None
assert meta[0]["favorite"] is True
def test_migration_adds_columns_on_old_database(tmp_path):
"""A pre-S2 database (workflows table without the metadata columns)
migrates in place; existing rows survive with defaults."""
db_path = tmp_path / "old.db"
conn = sqlite3.connect(db_path)
conn.executescript(
"""
CREATE TABLE workflows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
kind TEXT NOT NULL CHECK(kind IN ('image', 'video')),
workflow_json TEXT NOT NULL,
description TEXT,
prompt_profile TEXT NOT NULL DEFAULT 'prose',
is_enabled INTEGER NOT NULL DEFAULT 1,
usage TEXT,
usage_source TEXT,
output_map TEXT,
output_map_source TEXT
);
INSERT INTO workflows (name, kind, workflow_json)
VALUES ('legacy', 'video', '{"1": {"class_type": "SaveImage", "inputs": {}}}');
"""
)
conn.commit()
conn.close()
import asyncio
asyncio.run(migrate_db(db_path))
conn = sqlite3.connect(db_path)
cols = {r[1] for r in conn.execute("PRAGMA table_info(workflows)").fetchall()}
assert {
"category",
"model_family",
"media_io",
"tags",
"favorite",
"last_used_at",
"source",
"source_path",
} <= cols
row = conn.execute("SELECT name, favorite, tags, category FROM workflows").fetchone()
assert row == ("legacy", 0, None, None)
conn.close()
@@ -0,0 +1,235 @@
"""HEL-636: the two-step import closes the loop — analyze → confirm → save,
user corrections stick (reanalyze never overwrites them), pending workflows
cannot generate, and legacy rows migrate losslessly."""
from __future__ import annotations
import json
from pathlib import Path
from calliope.config import settings
from calliope.db import get_db, migrate_db
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
def _load(name: str) -> dict:
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
AMBIGUOUS = {
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
"5": {"class_type": "LoadImage", "inputs": {"image": "x.png"}},
"7": {
"class_type": "SomeVideoSampler",
"inputs": {"prompt": ["1", 0], "image": ["5", 0]},
},
"9": {"class_type": "SaveVideo", "inputs": {"images": ["7", 0]}},
}
def test_analyze_returns_usage_evidence_and_problem(client):
wf = _load("qwen_image_t2i_API.json")
r = client.post("/api/workflows/analyze", json={"workflow_json": wf})
assert r.status_code == 200
body = r.json()
assert body["usage"] == "text2image"
assert body["usage_ambiguous"] is False and body["needs_mapping"] is False
assert any(o["kind"] == "image" for o in body["usage_evidence"]["outputs"])
assert body["usable_usage"] == [
"text2image", "image2image", "text2video", "image2video", "ref2video",
]
r = client.post("/api/workflows/analyze", json={"workflow_json": AMBIGUOUS})
body = r.json()
assert body["usage"] is None and body["usage_ambiguous"] is True
assert "首帧还是参考图" in body["usage_problem"]
assert body["usage_evidence"]["image_slots"][0]["nodeId"] == "5"
bare = {"1": {"class_type": "Note", "inputs": {}}}
r = client.post("/api/workflows/analyze", json={"workflow_json": bare})
body = r.json()
assert "未在图中找到输出节点" in body["usage_problem"]
def test_create_stores_auto_usage_and_kind(client):
r = client.post(
"/api/workflows",
json={"name": "自动·文生图", "workflow_json": _load("qwen_image_t2i_API.json")},
)
assert r.status_code == 200, r.text
wf = r.json()
assert wf["usage"] == "text2image" and wf["usage_source"] == "auto"
assert wf["kind"] == "image" and wf["is_enabled"] is True
r = client.post(
"/api/workflows",
json={"name": "自动·参考生视频", "workflow_json": _load("h3_ref2va_05_API.json")},
)
wf = r.json()
assert wf["usage"] == "ref2video" and wf["kind"] == "video"
def test_ambiguous_import_stays_pending_and_cannot_generate(client):
r = client.post("/api/workflows", json={"name": "歧义", "workflow_json": AMBIGUOUS})
assert r.status_code == 200
wf = r.json()
assert wf["usage"] is None and wf["is_enabled"] is False
gen = client.post(
"/api/playground/generate",
json={"workflow_id": wf["id"], "input_values": {"1": "x"}},
)
assert gen.status_code == 400
assert "已停用" in gen.json()["detail"]
assert client.get("/api/playground/jobs").json() == []
def test_confirm_page_correction_roundtrip(client):
"""analyze → user picks usage + fixes the slot role → create carries both."""
analyzed = client.post("/api/workflows/analyze", json={"workflow_json": AMBIGUOUS}).json()
assert analyzed["usage"] is None
slot = analyzed["usage_evidence"]["image_slots"][0]
schema = [
{**i, "role": "first_frame", "manual": True} if i["nodeId"] == slot["nodeId"] else i
for i in analyzed["inputs"]
]
r = client.post(
"/api/workflows",
json={
"name": "确认·图生视频",
"workflow_json": AMBIGUOUS,
"usage": "image2video",
"usage_source": "user",
"input_schema": schema,
},
)
assert r.status_code == 200, r.text
wf = r.json()
assert wf["usage"] == "image2video" and wf["usage_source"] == "user"
assert wf["kind"] == "video" and wf["is_enabled"] is True
# the manual role sticks on read (and through re-derivation)
entry = next(i for i in wf["input_schema"] if i["nodeId"] == slot["nodeId"])
assert entry["role"] == "first_frame"
# reanalyze refreshes the auto schema but never the user's usage
r = client.post(f"/api/workflows/{wf['id']}/reanalyze")
assert r.status_code == 200
fresh = r.json()
assert fresh["usage"] == "image2video" and fresh["usage_source"] == "user"
entry = next(i for i in fresh["input_schema"] if i["nodeId"] == slot["nodeId"])
assert entry["role"] == "first_frame" and entry.get("manual") is True
def test_patch_usage_confirms_pending_workflow(client):
r = client.post("/api/workflows", json={"name": "待确认", "workflow_json": AMBIGUOUS})
wid = r.json()["id"]
assert r.json()["is_enabled"] is False
r = client.patch(f"/api/workflows/{wid}", json={"usage": "ref2video"})
assert r.status_code == 200
wf = r.json()
assert wf["usage"] == "ref2video" and wf["usage_source"] == "user"
assert wf["kind"] == "video" and wf["is_enabled"] is True
listed = next(w for w in client.get("/api/workflows").json() if w["id"] == wid)
assert listed["usage"] == "ref2video"
def test_reanalyze_upgrades_auto_usage_only(client):
r = client.post(
"/api/workflows",
json={"name": "重分析", "workflow_json": _load("qwen_image_t2i_API.json")},
)
wid = r.json()["id"]
r = client.patch(f"/api/workflows/{wid}", json={"usage": "image2image", "usage_source": "user"})
assert r.json()["usage"] == "image2image"
r = client.post(f"/api/workflows/{wid}/reanalyze")
assert r.json()["usage"] == "image2image" # user decision survives
assert r.json()["usage_source"] == "user"
def test_legacy_kind_only_callers_still_work(client):
"""Old clients post kind without usage — determinate graphs classify, the
row is enabled and generation proceeds as before."""
r = client.post(
"/api/workflows",
json={"name": "旧调用", "kind": "image", "workflow_json": _load("qwen_image_t2i_API.json")},
)
assert r.status_code == 200
wf = r.json()
assert wf["usage"] == "text2image" and wf["is_enabled"] is True
def test_migration_backfills_usage_and_keeps_history(client):
"""A8: pre-HEL-636 rows (direct SQL, no usage) settle on re-migration;
determinate ones gain usage, ambiguous ones are parked disabled, and
existing jobs/history stay readable with their original kind."""
pid = client.post("/api/projects", json={"title": "存量项目", "idea": "x"}).json()["id"]
conn = get_db(settings.db_path)
try:
conn.execute(
"INSERT INTO workflows (name, kind, workflow_json, input_schema,"
" output_schema, is_enabled) VALUES (?, 'image', ?, '[]', '[]', 1)",
("存量·文生图", json.dumps(_load("qwen_image_t2i_API.json"))),
)
conn.execute(
"INSERT INTO workflows (name, kind, workflow_json, input_schema,"
" output_schema, is_enabled) VALUES (?, 'video', ?, '[]', '[]', 1)",
("存量·歧义", json.dumps(AMBIGUOUS)),
)
conn.execute(
"INSERT INTO jobs (project_id, kind, workflow_id, status, payload_json,"
" output_paths_json) VALUES (?, 'image', NULL, 'done', '{}', ?)",
(pid, json.dumps(["/old/result.png"])),
)
conn.commit()
finally:
conn.close()
import asyncio
asyncio.run(migrate_db(settings.db_path)) # idempotent re-run
wfs = {w["name"]: w for w in client.get("/api/workflows").json()}
assert wfs["存量·文生图"]["usage"] == "text2image"
assert wfs["存量·文生图"]["is_enabled"] is True
assert wfs["存量·歧义"]["usage"] is None
assert wfs["存量·歧义"]["is_enabled"] is False
assert wfs["存量·歧义"]["kind"] == "video" # old kind semantics untouched
jobs = client.get(f"/api/jobs?project_id={pid}").json()
assert any(
j["status"] == "done" and j["kind"] == "image" and j["output_paths"] == ["/old/result.png"]
for j in jobs
)
# a third run changes nothing (idempotent)
asyncio.run(migrate_db(settings.db_path))
wfs2 = {w["name"]: w for w in client.get("/api/workflows").json()}
assert wfs2["存量·文生图"]["usage"] == "text2image"
assert wfs2["存量·歧义"]["usage"] is None
def test_broken_qwen_import_stays_blocked_but_recorded(client):
"""The damaged Qwen export classifies fine (image2image); its structural
lint errors still refuse generation — a broken file case, not a usage case."""
wf_json = _load("qwen21_edit_sage_API.json")
r = client.post(
"/api/workflows/analyze", json={"workflow_json": wf_json}
)
assert r.json()["usage"] == "image2image"
r = client.post(
"/api/workflows", json={"name": "损坏·Qwen", "kind": "image", "workflow_json": wf_json}
)
assert r.status_code == 200
wf = r.json()
assert wf["usage"] == "image2image" and wf["is_enabled"] is True
assert any(w["level"] == "error" for w in wf["warnings"])
gen = client.post(
"/api/playground/generate",
json={"workflow_id": wf["id"], "input_values": {"4@prompt": "x"}},
)
assert gen.status_code == 400 and "重新导出" in gen.json()["detail"]
@@ -0,0 +1,272 @@
"""L2 连接核查(HEL-643 批次 1):导入/复查时用目标 ComfyUI 的 /object_info
预检节点与模型可用性。全部基于 fake ComfyUI 的脚本化 /object_info,
不针对任何固定 JSON 或模型名写死判定。"""
from __future__ import annotations
import json
import socket
from pathlib import Path
import pytest
from calliope.comfyui.client import clear_object_info_cache
from calliope.comfyui.preflight import check_workflow_against
from calliope.config import settings
from tests.test_studio_generation import _url
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
def _load_05() -> dict:
return json.loads((FIXTURES / "h3_ref2va_05_API.json").read_text(encoding="utf-8"))
def build_object_info(workflow: dict) -> dict:
"""A catalogue where every widget value the JSON carries is legal.
String widgets become combos whose candidates include the workflow's own
value (merged across nodes of the same class); numbers/booleans are typed
inputs; wired inputs are link types — none of those can mismatch.
"""
info: dict = {}
for node in workflow.values():
if not isinstance(node, dict):
continue
ct = str(node.get("class_type") or "")
if not ct:
continue
spec = info.setdefault(ct, {"input": {"required": {}}, "output": ["*"]})
required = spec["input"]["required"]
for name, val in (node.get("inputs") or {}).items():
if isinstance(val, str):
# Merge: two nodes of one class with different values → both legal.
if name in required and isinstance(required[name][0], list):
if val not in required[name][0]:
required[name][0] = [*required[name][0], val]
continue
required[name] = [[val], {}]
elif name in required:
continue
elif isinstance(val, list):
required[name] = ["*", {}]
elif isinstance(val, bool):
required[name] = ["BOOLEAN", {}]
elif isinstance(val, (int, float)):
required[name] = ["NUMBER", {}]
return info
@pytest.fixture(autouse=True)
def _fresh_cache():
clear_object_info_cache()
yield
clear_object_info_cache()
@pytest.fixture
def endpoint_at(client, fake_comfy): # noqa: F811
"""Register a connection pointing at the fake server; yields its id."""
prev_eps = [dict(ep) for ep in (settings.comfyui_endpoints or [])]
prev_active = settings.comfyui_active_id
r = client.post(
"/api/comfyui/endpoints",
json={"name": "预检桩", "base_url": _url(fake_comfy), "auth_type": "none"},
)
assert r.status_code == 200, r.text
endpoints = r.json()["endpoints"]
ep = next(e for e in endpoints if e["base_url"] == _url(fake_comfy))
settings.comfyui_endpoints = [dict(e) for e in endpoints if e["base_url"] == _url(fake_comfy)]
settings.comfyui_active_id = ep["id"]
settings.apply_active_comfyui()
try:
yield ep["id"]
finally:
settings.comfyui_endpoints = prev_eps
settings.comfyui_active_id = prev_active
settings.apply_active_comfyui()
# ---------------------------------------------------------------------------
# Unit rules
# ---------------------------------------------------------------------------
def test_unit_missing_node_reported_with_node_context():
wf = {"900": {"class_type": "H3SLAAttention", "inputs": {"enabled": True},
"_meta": {"title": "H3 SLA Attention"}}}
findings = check_workflow_against({"OtherNode": {}}, wf)
assert len(findings) == 1
f = findings[0]
assert f["code"] == "missing_node" and f["level"] == "error"
msg = f["message"]
assert "H3SLAAttention" in msg and "900" in msg and "H3 SLA Attention" in msg
assert "重新检查" in f["hint"]
def test_unit_combo_value_not_in_list():
wf = {"127": {"class_type": "UNETLoader",
"inputs": {"unet_name": "missing_model.safetensors", "weight_dtype": "default"}}}
info = {"UNETLoader": {"input": {"required": {
"unet_name": [["installed_model.safetensors"], {}],
"weight_dtype": [["default", "fp8"], {"upload": False}],
}}, "output": ["MODEL"]}}
findings = check_workflow_against(info, wf)
assert len(findings) == 1
f = findings[0]
assert f["code"] == "value_not_in_list" and f["node_id"] == "127"
assert "missing_model.safetensors" in f["message"] and "unet_name" in f["message"]
assert "模型文件" in f["message"]
assert "installed_model.safetensors" in f["hint"]
def test_unit_upload_backed_and_wired_inputs_skipped():
wf = {
"1": {"class_type": "LoadImage", "inputs": {"image": "placeholder.png"}},
"2": {"class_type": "LoadVideo", "inputs": {"file": "clip.mp4"}},
"3": {"class_type": "LoadAudio", "inputs": {"audio": "voice.wav"}},
"4": {"class_type": "KSampler", "inputs": {"seed": 7, "model": ["9", 0]}},
}
info = {
"LoadImage": {"input": {"required": {"image": [["other.png"], {"image_upload": True}]}},
"output": ["IMAGE"]},
"LoadVideo": {"input": {"required": {"file": [[], {}]}}, "output": ["IMAGE"]},
"LoadAudio": {"input": {"required": {"audio": [[], {}]}}, "output": ["AUDIO"]},
"KSampler": {"input": {"required": {"seed": ["INT", {}], "model": ["MODEL", {}]}},
"output": ["IMAGE"]},
}
assert check_workflow_against(info, wf) == []
# ---------------------------------------------------------------------------
# Analyze with a target connection
# ---------------------------------------------------------------------------
def test_analyze_all_green_against_fake_object_info(client, endpoint_at, fake_comfy):
fake_comfy.object_info = build_object_info(_load_05())
r = client.post(
"/api/workflows/analyze",
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
)
assert r.status_code == 200, r.text
check = r.json()["connection_check"]
assert check["reachable"] is True and check["ok"] is True
assert check["findings"] == []
def test_analyze_reports_missing_custom_node(client, endpoint_at, fake_comfy):
info = build_object_info(_load_05())
info.pop("H3SLAAttention")
fake_comfy.object_info = info
r = client.post(
"/api/workflows/analyze",
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
)
check = r.json()["connection_check"]
assert check["ok"] is False
missing = [f for f in check["findings"] if f["code"] == "missing_node"]
assert missing and "H3SLAAttention" in missing[0]["message"]
assert "900" in missing[0]["message"]
def test_analyze_reports_missing_model_value(client, endpoint_at, fake_comfy):
info = build_object_info(_load_05())
# The server has a DIFFERENT unet installed than the one the JSON names.
info["UNETLoader"]["input"]["required"]["unet_name"] = [["another_unet.safetensors"], {}]
fake_comfy.object_info = info
r = client.post(
"/api/workflows/analyze",
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
)
check = r.json()["connection_check"]
assert check["ok"] is False
bad = [f for f in check["findings"] if f["code"] == "value_not_in_list"]
assert bad and bad[0]["node_id"] == "127"
assert "minimax_h3_fused_refdelta" in bad[0]["message"]
def test_unreachable_connection_degrades_to_unchecked(client):
# A port that nothing listens on: the endpoint resolves, the server doesn't.
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
prev_eps = [dict(ep) for ep in (settings.comfyui_endpoints or [])]
prev_active = settings.comfyui_active_id
r = client.post(
"/api/comfyui/endpoints",
json={"name": "断连桩", "base_url": f"http://127.0.0.1:{port}", "auth_type": "none"},
)
endpoints = r.json()["endpoints"]
ep = next(e for e in endpoints if e["base_url"] == f"http://127.0.0.1:{port}")
settings.comfyui_endpoints = [dict(e) for e in endpoints if e["id"] == ep["id"]]
settings.comfyui_active_id = ep["id"]
settings.apply_active_comfyui()
try:
r = client.post(
"/api/workflows/analyze",
json={"workflow_json": _load_05(), "endpoint_id": ep["id"]},
)
check = r.json()["connection_check"]
assert check["reachable"] is False and check["ok"] is False
assert "无法核查" in check["message"]
assert check["findings"] == []
finally:
settings.comfyui_endpoints = prev_eps
settings.comfyui_active_id = prev_active
settings.apply_active_comfyui()
# ---------------------------------------------------------------------------
# Saved-workflow re-check + cache
# ---------------------------------------------------------------------------
def _create_workflow(client, workflow: dict, endpoint_id: str | None = None) -> int:
r = client.post(
"/api/workflows",
json={
"name": "预检工作流",
"workflow_json": workflow,
"usage": "ref2video",
**({"endpoint_id": endpoint_id} if endpoint_id else {}),
},
)
assert r.status_code == 200, r.text
return r.json()["id"]
def test_saved_workflow_recheck_and_refresh(client, endpoint_at, fake_comfy):
wf_id = _create_workflow(client, _load_05(), endpoint_at)
info = build_object_info(_load_05())
info.pop("UNETLoader")
fake_comfy.object_info = info
r = client.post(f"/api/workflows/{wf_id}/check", json={})
assert r.status_code == 200, r.text
body = r.json()
assert body["ok"] is False and body["endpoint_id"] == endpoint_at
assert any(f["code"] == "missing_node" for f in body["findings"])
# User installs the node package, comes back and re-checks with refresh.
fake_comfy.object_info = build_object_info(_load_05())
r = client.post(f"/api/workflows/{wf_id}/check", json={"refresh": True})
body = r.json()
assert body["ok"] is True and body["findings"] == []
def test_object_info_cached_within_ttl(client, endpoint_at, fake_comfy):
fake_comfy.object_info = build_object_info(_load_05())
for _ in range(2):
r = client.post(
"/api/workflows/analyze",
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
)
assert r.json()["connection_check"]["ok"] is True
hits = [p for p in fake_comfy.requests if p == "/object_info"]
assert len(hits) == 1 # second call served from the 5-minute cache
wf_id = _create_workflow(client, _load_05())
client.post(f"/api/workflows/{wf_id}/check", json={"refresh": True})
hits = [p for p in fake_comfy.requests if p == "/object_info"]
assert len(hits) == 2 # manual refresh bypasses the TTL
@@ -0,0 +1,197 @@
"""「测试工作流」(HEL-643 批次 2)端到端:真实队列 → fake ComfyUI → 产物隔离。
测试任务与普通任务区分(jobs.origin)、dry-run 拒绝、幂等复用、
保留策略与级联清理,全部对着 fake ComfyUI 跑真实链路。
"""
from __future__ import annotations
import time
from pathlib import Path
from calliope.config import settings
from calliope.queue.workflow_test import workflow_test_dir
from tests.test_studio_generation import (
IMAGE_WORKFLOW,
PNG_1PX,
_real_run,
)
def _wait_terminal(client, job_id: int, timeout: float = 25.0) -> dict:
deadline = time.time() + timeout
while time.time() < deadline:
r = client.get(f"/api/jobs/{job_id}")
assert r.status_code == 200, r.text
job = r.json()
if job["status"] not in ("pending", "running"):
return job
time.sleep(0.4)
raise AssertionError(f"test job {job_id} did not finish in {timeout}s")
def _create_workflow(client, workflow: dict, name: str = "可测工作流") -> int:
r = client.post(
"/api/workflows",
json={"name": name, "workflow_json": workflow, "usage": "text2image"},
)
assert r.status_code == 200, r.text
return r.json()["id"]
def test_passing_test_downloads_real_output_to_scratch_dir(client, fake_comfy):
with _real_run(client, fake_comfy):
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="通过用例")
r = client.post(
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "测试提示词"}}
)
assert r.status_code == 200, r.text
body = r.json()
assert body["reused"] is False
job = _wait_terminal(client, body["job"]["id"])
assert job["status"] == "done", job.get("error")
assert job["origin"] == "workflow_test"
assert job["payload"]["source"] == "workflow_test"
assert job["phase"] == "finished"
# 真实拿到可下载输出才算通过:产物落在独立 scratch 目录
assert len(job["output_paths"]) >= 1
out = Path(job["output_paths"][0])
assert out.is_file() and out.read_bytes() == PNG_1PX
assert workflow_test_dir(wf_id) in out.parents
# 提交给 fake ComfyUI 的是补丁后的真实提示词
queued = fake_comfy.prompts[-1]["prompt"]
assert queued["1"]["inputs"]["text"] == "测试提示词"
def test_tasks_list_shows_origin_but_playground_history_hides_it(client, fake_comfy):
with _real_run(client, fake_comfy):
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="可见性用例")
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
job_id = r.json()["job"]["id"]
_wait_terminal(client, job_id)
listed = client.get("/api/jobs?limit=50").json()
mine = [j for j in listed if j["id"] == job_id]
assert mine and mine[0]["origin"] == "workflow_test"
pg = client.get("/api/playground/jobs").json()
assert all(j["id"] != job_id for j in pg), "test jobs must stay out of studio history"
def test_failed_prompt_reports_chinese_reason(client, fake_comfy):
with _real_run(client, fake_comfy):
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="排队被拒用例")
fake_comfy.fail_prompt = True
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
job = _wait_terminal(client, r.json()["job"]["id"])
assert job["status"] == "failed"
assert "拒绝执行" in (job["error"] or "")
def test_no_output_does_not_pass(client, fake_comfy):
with _real_run(client, fake_comfy):
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="无输出用例")
fake_comfy.drop_outputs = True
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
job = _wait_terminal(client, r.json()["job"]["id"])
assert job["status"] == "failed"
assert "没有返回任何图片或视频" in (job["error"] or "")
def test_dry_run_setting_refuses_real_test(client, fake_comfy):
with _real_run(client, fake_comfy):
settings.dry_run = True # conftest default; _real_run clears it — set again
try:
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="dry 拒绝用例")
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
assert r.status_code == 400
assert "Dry-run" in r.json()["detail"]
finally:
settings.dry_run = False
def test_repeat_click_reuses_inflight_job(client, fake_comfy):
with _real_run(client, fake_comfy):
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="幂等用例")
assert client.post("/api/jobs/pause").status_code == 200
try:
r1 = client.post(
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "一次就好"}}
)
assert r1.status_code == 200 and r1.json()["reused"] is False
r2 = client.post(
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "重复点击"}}
)
assert r2.status_code == 200
assert r2.json()["reused"] is True
assert r2.json()["job"]["id"] == r1.json()["job"]["id"]
finally:
client.post("/api/jobs/resume")
client.post(f"/api/jobs/{r1.json()['job']['id']}/cancel")
_wait_terminal(client, r1.json()["job"]["id"])
def test_retention_keeps_newest_records_and_prunes_artifacts(client, fake_comfy):
with _real_run(client, fake_comfy):
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="保留策略用例")
for i in range(7):
fake_comfy.out_name = f"t{i}.png"
r = client.post(
f"/api/workflows/{wf_id}/test",
json={"input_values": {"1": f"第{i}次"}},
)
_wait_terminal(client, r.json()["job"]["id"])
rows = client.get(f"/api/workflows/{wf_id}/tests").json()
assert len(rows) <= 5
kept_ids = {row["id"] for row in rows}
# 被清掉的旧记录其产物文件也一并删除
leftovers = [p for p in workflow_test_dir(wf_id).iterdir() if p.suffix == ".png"]
assert all(p.stem.lstrip("t").isdigit() and int(p.stem[1:]) >= 2 for p in leftovers)
assert len(kept_ids) == 5
def test_clear_tests_and_workflow_delete_cascade(client, fake_comfy):
with _real_run(client, fake_comfy):
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="清理用例")
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
_wait_terminal(client, r.json()["job"]["id"])
assert workflow_test_dir(wf_id).is_dir()
r = client.post(f"/api/workflows/{wf_id}/clear-tests")
assert r.status_code == 200 and r.json()["removed"] >= 1
assert not workflow_test_dir(wf_id).exists()
assert client.get(f"/api/workflows/{wf_id}/tests").json() == []
# 进行中的测试:先拒绝清除,取消后删除工作流级联清理
assert client.post("/api/jobs/pause").status_code == 200
try:
job_id = client.post(
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}}
).json()["job"]["id"]
r = client.post(f"/api/workflows/{wf_id}/clear-tests")
assert r.status_code == 409
finally:
client.post("/api/jobs/resume")
client.post(f"/api/jobs/{job_id}/cancel")
_wait_terminal(client, job_id)
# 工作流列表带出「上次测试」实时状态
listed = client.get("/api/workflows").json()
mine = [w for w in listed if w["id"] == wf_id][0]
assert mine["last_test"]["id"] == job_id
assert client.delete(f"/api/workflows/{wf_id}").status_code == 200
assert not workflow_test_dir(wf_id).exists()
assert client.get(f"/api/workflows/{wf_id}/tests").status_code == 404
def test_last_test_summary_live_on_list(client, fake_comfy):
with _real_run(client, fake_comfy):
wf_a = _create_workflow(client, IMAGE_WORKFLOW, name="A")
wf_b = _create_workflow(client, IMAGE_WORKFLOW, name="B")
r = client.post(f"/api/workflows/{wf_a}/test", json={"input_values": {"1": "p"}})
done = _wait_terminal(client, r.json()["job"]["id"])
listed = {w["id"]: w for w in client.get("/api/workflows").json()}
assert listed[wf_a]["last_test"]["status"] == "done"
assert listed[wf_a]["last_test"]["output_count"] == len(done["output_paths"])
assert listed[wf_b]["last_test"] is None
+49
View File
@@ -0,0 +1,49 @@
import js from "@eslint/js";
import globals from "globals";
import tsParser from "@typescript-eslint/parser";
import tsPlugin from "@typescript-eslint/eslint-plugin";
import svelte from "eslint-plugin-svelte";
export default [
// SvelteKit sync and adapter-static output; these are not source files.
{ ignores: [".svelte-kit/**", "build/**"] },
js.configs.recommended,
...svelte.configs["flat/recommended"],
// v3 removed these from recommended; retain the previous checks explicitly.
{
rules: {
"svelte/valid-compile": "error",
"svelte/no-dynamic-slot-name": "error",
},
},
{
files: ["**/*.{ts,svelte}"],
plugins: { "@typescript-eslint": tsPlugin },
rules: {
...tsPlugin.configs["eslint-recommended"].overrides[0].rules,
...tsPlugin.configs.recommended.rules,
},
},
{
files: ["**/*.ts"],
ignores: ["**/*.svelte.ts"],
languageOptions: { parser: tsParser },
},
{
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
languageOptions: { parserOptions: { parser: tsParser } },
rules: {
"prefer-const": "off",
"svelte/prefer-const": "error",
},
},
{
files: ["src/**/*.{js,ts,svelte}"],
ignores: ["src/hooks.server.ts"],
languageOptions: { globals: globals.browser },
},
{
files: ["*.{js,ts}", "scripts/**/*.mjs", "src/hooks.server.ts"],
languageOptions: { globals: globals.node },
},
];
+4511 -4256
View File
File diff suppressed because it is too large Load Diff
+39 -37
View File
@@ -1,39 +1,41 @@
{
"name": "calliope-web",
"version": "1.5.8",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "node scripts/check-storage-ssr.mjs && node scripts/check-random-uuid.mjs",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.70.3",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/node": "^22.13.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.20.0",
"eslint-plugin-svelte": "^2.46.0",
"postcss": "^8.5.0",
"prettier": "^3.5.0",
"prettier-plugin-svelte": "^3.3.0",
"svelte": "^5.20.0",
"svelte-check": "^4.1.0",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.0",
"vite": "^6.1.0"
},
"dependencies": {
"@tanstack/svelte-query": "^5.66.0",
"@types/three": "0.185.0",
"@xyflow/svelte": "^1.6.5",
"mannequin-js": "5.2.3",
"three": "0.185.1"
},
"type": "module"
"name": "calliope-web",
"version": "1.5.8",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "node scripts/check-storage-ssr.mjs && node scripts/check-random-uuid.mjs && node scripts/check-skill-frontmatter.mjs && node scripts/check-export-frame.mjs",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.70.3",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/node": "^22.13.0",
"@typescript-eslint/eslint-plugin": "8.46.2",
"@typescript-eslint/parser": "8.46.2",
"autoprefixer": "^10.4.20",
"eslint": "^9.20.0",
"eslint-plugin-svelte": "3.23.1",
"postcss": "^8.5.0",
"prettier": "^3.5.0",
"prettier-plugin-svelte": "^3.3.0",
"svelte": "^5.20.0",
"svelte-check": "^4.1.0",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.0",
"vite": "^6.1.0"
},
"dependencies": {
"@tanstack/svelte-query": "^5.66.0",
"@types/three": "0.185.0",
"@xyflow/svelte": "^1.6.5",
"mannequin-js": "5.2.3",
"three": "0.185.1"
},
"type": "module"
}
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env node
/**
* Pure-logic checks for 导出尾帧 (lib/media/exportFrame.ts): which media
* times get tried near the end, how the PNG is named, and when a sampled
* frame counts as black / empty. The decode + download path itself runs in
* a real browser (e2e/lastframe.mjs).
*
* Run with `npm test` (or `node scripts/check-export-frame.mjs`).
*/
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
const MODULE = join(HERE, "..", "src", "lib", "media", "exportFrame.ts");
const {
tailSeekTimes,
videoBaseName,
frameFileName,
blankKind,
statusErrorCode,
TAIL_OFFSETS,
} = await import(pathToFileURL(MODULE).href);
let failures = 0;
function check(name, cond, extra = "") {
if (cond) console.log(`ok ${name}`);
else {
failures += 1;
console.error(`FAIL ${name}${extra ? ` — ${extra}` : ""}`);
}
}
// ---- seek targets -------------------------------------------------------
const t6 = tailSeekTimes(6.04);
check(
"first try is ~1 frame before the end, never the end itself",
t6[0] < 6.04 && 6.04 - t6[0] <= 0.05 + 1e-9,
JSON.stringify(t6),
);
check(
"later tries step strictly further back",
t6.every((t, i) => i === 0 || t < t6[i - 1]),
JSON.stringify(t6),
);
check(
"tries stay within ~1s of the end",
t6.length === TAIL_OFFSETS.length && 6.04 - t6[t6.length - 1] <= 1 + 1e-9,
);
check(
"short clip clamps to 0 without duplicates",
JSON.stringify(tailSeekTimes(0.1)) === "[0.05,0]",
);
check(
"unknown / zero duration falls back to t=0",
JSON.stringify(tailSeekTimes(NaN)) === "[0]" &&
JSON.stringify(tailSeekTimes(0)) === "[0]",
);
// ---- file names ---------------------------------------------------------
const api =
"/api/file?path=" +
encodeURIComponent(
"/opt/aigc-home/data/assets/1/video/05_ref2va_4step_sla_00035_.mp4",
);
check(
"base name from /api/file?path=…",
videoBaseName(api) === "05_ref2va_4step_sla_00035_",
);
check(
"base name from a Windows path",
videoBaseName(
"/api/file?path=" +
encodeURIComponent("D:\\calliope\\assets\\镜头 01.webm"),
) === "镜头 01",
);
check(
"base name from a plain URL",
videoBaseName("http://x.test/media/clip-9.mov?x=1") === "clip-9",
);
check(
"unsafe characters are replaced",
!/[:*?"<>|]/.test(
videoBaseName("/api/file?path=" + encodeURIComponent('a:b*c?"d<e>f|g.mp4')),
),
);
check(
"blob URL without a name falls back to 'video'",
videoBaseName("blob:http://x.test/") === "video",
);
const fname = frameFileName(api, 5.958, "尾帧");
check(
"PNG name names the video, the tag and the frame time",
fname === "05_ref2va_4step_sla_00035__尾帧_5.958s.png",
fname,
);
check(
"spaces in the tag become dashes",
frameFileName(api, 1, "last frame").includes("_last-frame_1.000s.png"),
);
// ---- black / empty detection -------------------------------------------
function frame(n, rgba) {
const out = new Uint8ClampedArray(n * 4);
for (let i = 0; i < n; i++) out.set(rgba(i), i * 4);
return out;
}
const N = 64 * 64;
check(
"fully transparent sample → empty",
blankKind(frame(N, () => [0, 0, 0, 0])) === "empty",
);
check(
"pure black → black",
blankKind(frame(N, () => [0, 0, 0, 255])) === "black",
);
check(
"near-black encoder noise (16..18) → black",
blankKind(frame(N, (i) => [16 + (i % 3), 16, 16, 255])) === "black",
);
check(
"mid-grey picture → not blank",
blankKind(frame(N, () => [128, 128, 128, 255])) === null,
);
check(
"dim but visible picture (≈50) → not blank",
blankKind(frame(N, () => [50, 48, 52, 255])) === null,
);
check(
"dark night scene with a bright highlight → not blank",
blankKind(
frame(N, (i) => (i === 100 ? [250, 240, 200, 255] : [4, 4, 6, 255])),
) === null,
);
// ---- HTTP status classification ----------------------------------------
check("404 → not-found", statusErrorCode(404) === "not-found");
check(
"403 → forbidden",
statusErrorCode(403) === "forbidden" && statusErrorCode(401) === "forbidden",
);
check("500 → network", statusErrorCode(500) === "network");
check(
"206 / 200 are not errors",
statusErrorCode(206) === null && statusErrorCode(200) === null,
);
if (failures) {
console.error(`\n${failures} export-frame check(s) failed.`);
process.exit(1);
}
console.log("export-frame checks passed.");
@@ -0,0 +1,121 @@
#!/usr/bin/env node
/**
* Regression guard for the HEL-613 rework: the skill editor must load the
* SKILL.md BODY only — never the YAML frontmatter — so saving cannot wrap
* the file in a second frontmatter layer (each edit used to nest one more
* `---\nname: …` block into the content: the old strip regex anchored on
* `\A`, a Python-ism JS treats as a literal, so it never matched).
*
* Layers:
* 1. behavioural — stripSkillFrontmatter() must strip the exact block
* shape the backend's render_skill_md writes, split at the right
* boundary, be idempotent (the no-doubling invariant), and pass
* plain bodies through unchanged.
* 2. round-trip — strip → re-render (frontmatter + body) → strip again
* yields the identical body: editing twice adds zero layers.
* 3. static — the editor page must use the shared helper; the inline
* `\A`-anchored regex cannot creep back in.
*
* Run with `npm test` (or `node scripts/check-skill-frontmatter.mjs`).
*/
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..");
let failed = false;
function check(name, cond, detail = "") {
console.log(
`${cond ? "ok " : "FAIL "} ${name}${cond ? "" : ` :: ${detail}`}`,
);
if (!cond) failed = true;
}
// Mirror of the backend's render_skill_md output shape
// (calliope-backend/src/calliope/agent/skills_store.py).
const rendered = [
"---",
"name: cyberpunk-style-kit",
'description: "赛博朋克风格词库"',
"version: 1.0.1",
"metadata:",
" author: user",
" tags: [neon, rain]",
" type: template",
"---",
"",
"# 赛博朋克风格词库",
"neon, rain, hologram",
].join("\n");
const { stripSkillFrontmatter } = await import(
pathToFileURL(join(ROOT, "src/lib/skillFrontmatter.ts"))
);
// 1. Editor load: body only, no frontmatter.
const body = stripSkillFrontmatter(rendered);
check(
"edit load strips the frontmatter block",
!body.includes("name: cyberpunk-style-kit") &&
!body.trimStart().startsWith("---"),
JSON.stringify(body.slice(0, 40)),
);
check(
"edit load keeps the body",
body.includes("# 赛博朋克风格词库") && body.includes("neon, rain, hologram"),
JSON.stringify(body),
);
// 2. Strip splits at the exact boundary: frontmatter + body reassembles
// the file byte-identically (proves no body text was eaten).
const fm = rendered.slice(0, rendered.length - body.length);
check(
"strip boundary is exact",
fm + body === rendered && fm.trimEnd().endsWith("---"),
"frontmatter/body split drifted",
);
// 3. No-doubling invariant: idempotent strip + round-trip stability.
check(
"strip is idempotent",
stripSkillFrontmatter(body) === body,
"second strip changed the body",
);
const savedAgain = fm + body; // render_skill_md re-wraps the same frontmatter
check(
"second edit-save-reload adds no layer",
stripSkillFrontmatter(savedAgain) === body,
"round-trip grew the content",
);
// 4. Body without frontmatter passes through unchanged.
const plain = "# Just a body\nwith text\n";
check(
"plain body unchanged",
stripSkillFrontmatter(plain) === plain,
JSON.stringify(stripSkillFrontmatter(plain)),
);
// 5. Static: the extracted skill editor uses the shared helper; no `\A` regex remains.
const page = readFileSync(
join(ROOT, "src/lib/components/agent/LegacyAgentWorkspace.svelte"),
"utf-8",
);
check(
"page imports the shared strip helper",
page.includes("stripSkillFrontmatter"),
"helper import missing",
);
check(
"no \\A-anchored strip regex left behind",
!/\\A---/.test(page),
"inline \\A regex present",
);
if (failed) {
console.error("skill frontmatter checks FAILED");
process.exit(1);
}
console.log("skill frontmatter checks passed.");
+105 -94
View File
@@ -16,51 +16,53 @@
*
* Run with `npm test` (or `node scripts/check-storage-ssr.mjs`).
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { readFileSync, readdirSync, statSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..');
const SRC = join(ROOT, 'src');
const HELPER = join(SRC, 'lib', 'storage.ts');
const ROOT = join(HERE, "..");
const SRC = join(ROOT, "src");
const HELPER = join(SRC, "lib", "storage.ts");
let failures = 0;
function fail(message) {
failures += 1;
console.error(`FAIL ${message}`);
failures += 1;
console.error(`FAIL ${message}`);
}
function ok(message) {
console.log(`ok ${message}`);
console.log(`ok ${message}`);
}
/** Replace the global under test. Node's own binding may be getter-only. */
function setGlobalStorage(value) {
Object.defineProperty(globalThis, 'localStorage', {
value,
configurable: true,
writable: true,
});
Object.defineProperty(globalThis, "localStorage", {
value,
configurable: true,
writable: true,
});
}
/** A Storage that behaves like a browser's. */
function fakeStorage(initial = {}) {
const map = new Map(Object.entries(initial));
return {
getItem: (k) => (map.has(k) ? map.get(k) : null),
setItem: (k, v) => void map.set(k, String(v)),
removeItem: (k) => void map.delete(k),
clear: () => map.clear(),
key: (i) => [...map.keys()][i] ?? null,
get length() {
return map.size;
},
};
const map = new Map(Object.entries(initial));
return {
getItem: (k) => (map.has(k) ? map.get(k) : null),
setItem: (k, v) => void map.set(k, String(v)),
removeItem: (k) => void map.delete(k),
clear: () => map.clear(),
key: (i) => [...map.keys()][i] ?? null,
get length() {
return map.size;
},
};
}
const { storageGet, storageSet, storageRemove } = await import(pathToFileURL(HELPER).href);
const { storageGet, storageSet, storageRemove } = await import(
pathToFileURL(HELPER).href
);
// ---- 1. behavioural ----------------------------------------------------
@@ -68,124 +70,133 @@ const { storageGet, storageSet, storageRemove } = await import(pathToFileURL(HEL
// This is the configuration that produced the original crash.
setGlobalStorage({});
try {
if (storageGet('calliope-lang') !== null) fail('method-less localStorage: expected null');
else if (storageSet('calliope-lang', 'ja') !== undefined) fail('method-less localStorage: setItem should no-op');
else {
storageRemove('calliope-lang');
ok('method-less localStorage (Node SSR shape) degrades to null without throwing');
}
if (storageGet("calliope-lang") !== null)
fail("method-less localStorage: expected null");
else if (storageSet("calliope-lang", "ja") !== undefined)
fail("method-less localStorage: setItem should no-op");
else {
storageRemove("calliope-lang");
ok(
"method-less localStorage (Node SSR shape) degrades to null without throwing",
);
}
} catch (err) {
fail(`method-less localStorage threw: ${err.message}`);
fail(`method-less localStorage threw: ${err.message}`);
}
// Absent entirely (the common server case).
setGlobalStorage(undefined);
try {
if (storageGet('k') !== null) fail('undefined localStorage: expected null');
else ok('absent localStorage degrades to null');
if (storageGet("k") !== null) fail("undefined localStorage: expected null");
else ok("absent localStorage degrades to null");
} catch (err) {
fail(`undefined localStorage threw: ${err.message}`);
fail(`undefined localStorage threw: ${err.message}`);
}
// Access itself can throw (sandboxed frame / blocked third-party storage).
setGlobalStorage(
new Proxy(
{},
{
get() {
throw new Error('SecurityError');
},
},
),
new Proxy(
{},
{
get() {
throw new Error("SecurityError");
},
},
),
);
try {
if (storageGet('k') !== null) fail('throwing localStorage: expected null');
else ok('throwing localStorage access degrades to null');
if (storageGet("k") !== null) fail("throwing localStorage: expected null");
else ok("throwing localStorage access degrades to null");
} catch (err) {
fail(`throwing localStorage access threw: ${err.message}`);
fail(`throwing localStorage access threw: ${err.message}`);
}
// Quota / private-mode errors on write must not escape.
setGlobalStorage({
getItem: () => null,
setItem: () => {
throw new Error('QuotaExceededError');
},
removeItem: () => {},
getItem: () => null,
setItem: () => {
throw new Error("QuotaExceededError");
},
removeItem: () => {},
});
try {
storageSet('k', 'v');
ok('setItem failure is swallowed');
storageSet("k", "v");
ok("setItem failure is swallowed");
} catch (err) {
fail(`setItem failure escaped: ${err.message}`);
fail(`setItem failure escaped: ${err.message}`);
}
// A real browser Storage must still work — guards against a helper that just
// returns null unconditionally to satisfy the checks above.
setGlobalStorage(fakeStorage());
try {
storageSet('calliope-lang', 'ko');
const read = storageGet('calliope-lang');
if (read !== 'ko') fail(`working storage round-trip: expected "ko", got ${JSON.stringify(read)}`);
else {
storageRemove('calliope-lang');
if (storageGet('calliope-lang') !== null) fail('working storage remove: expected null');
else ok('working storage round-trips and removes');
}
storageSet("calliope-lang", "ko");
const read = storageGet("calliope-lang");
if (read !== "ko")
fail(
`working storage round-trip: expected "ko", got ${JSON.stringify(read)}`,
);
else {
storageRemove("calliope-lang");
if (storageGet("calliope-lang") !== null)
fail("working storage remove: expected null");
else ok("working storage round-trips and removes");
}
} catch (err) {
fail(`working storage threw: ${err.message}`);
fail(`working storage threw: ${err.message}`);
}
// ---- 2. static ---------------------------------------------------------
const UNSAFE = /typeof\s+localStorage/;
const EXTENSIONS = ['.ts', '.js', '.svelte'];
const EXTENSIONS = [".ts", ".js", ".svelte"];
function walk(dir) {
const found = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules') continue;
found.push(...walk(full));
} else if (EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {
found.push(full);
}
}
return found;
const found = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "node_modules") continue;
found.push(...walk(full));
} else if (EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {
found.push(full);
}
}
return found;
}
function exists(p) {
try {
return statSync(p).isFile();
} catch {
return false;
}
try {
return statSync(p).isFile();
} catch {
return false;
}
}
const offenders = [];
for (const file of walk(SRC)) {
if (file === HELPER) continue; // the one place allowed to probe the global
const lines = readFileSync(file, 'utf8').split('\n');
lines.forEach((line, i) => {
if (UNSAFE.test(line)) offenders.push(`${relative(ROOT, file)}:${i + 1}: ${line.trim()}`);
});
if (file === HELPER) continue; // the one place allowed to probe the global
const lines = readFileSync(file, "utf8").split("\n");
lines.forEach((line, i) => {
if (UNSAFE.test(line))
offenders.push(`${relative(ROOT, file)}:${i + 1}: ${line.trim()}`);
});
}
if (offenders.length) {
fail(
`unsafe \`typeof localStorage\` guard outside lib/storage.ts — use storageGet/storageSet instead:\n ${offenders.join('\n ')}`,
);
fail(
`unsafe \`typeof localStorage\` guard outside lib/storage.ts — use storageGet/storageSet instead:\n ${offenders.join("\n ")}`,
);
} else {
ok('no unsafe `typeof localStorage` guards in src/');
ok("no unsafe `typeof localStorage` guards in src/");
}
if (!exists(HELPER)) fail('src/lib/storage.ts is missing');
if (!exists(HELPER)) fail("src/lib/storage.ts is missing");
// ---- result ------------------------------------------------------------
if (failures) {
console.error(`\n${failures} storage/SSR check(s) failed.`);
process.exit(1);
console.error(`\n${failures} storage/SSR check(s) failed.`);
process.exit(1);
}
console.log('\nstorage SSR checks passed.');
console.log("\nstorage SSR checks passed.");
+29 -18
View File
@@ -1,19 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Calliope Lab</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=Noto+Sans+SC:wght@400;500;600&display=swap"
rel="stylesheet"
/>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<script>
(function () {
var saved;
try {
saved = localStorage.getItem("aigc-theme");
} catch (_) {}
document.documentElement.dataset.theme =
/^(night|day|grade|bluehour)$/.test(saved || "") ? saved : "night";
})();
</script>
<link rel="stylesheet" href="%sveltekit.assets%/themes.css" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>影工坊</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=Noto+Sans+SC:wght@400;500;600&display=swap"
rel="stylesheet"
/>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+6 -6
View File
@@ -1,9 +1,9 @@
import type { HandleClientError } from '@sveltejs/kit';
import { t } from '$lib/i18n.svelte';
import type { HandleClientError } from "@sveltejs/kit";
import { t } from "$lib/i18n.svelte";
export const handleError: HandleClientError = ({ error }) => {
console.error(error);
return {
message: t('global.somethingWentWrong'),
};
console.error(error);
return {
message: t("global.somethingWentWrong"),
};
};
+2 -2
View File
@@ -1,4 +1,4 @@
export async function handle({ event, resolve }) {
const response = await resolve(event);
return response;
const response = await resolve(event);
return response;
}
+17
View File
@@ -0,0 +1,17 @@
import type { Action } from "svelte/action";
/** A local honeycomb highlight follows the pointer, independent of selected state. */
export const spotlight: Action<HTMLElement> = (node) => {
const move = (event: PointerEvent) => {
if (
document.documentElement.dataset.reduce === "1" ||
event.pointerType === "touch"
)
return;
const rect = node.getBoundingClientRect();
node.style.setProperty("--pointer-x", `${event.clientX - rect.left}px`);
node.style.setProperty("--pointer-y", `${event.clientY - rect.top}px`);
};
node.addEventListener("pointermove", move);
return { destroy: () => node.removeEventListener("pointermove", move) };
};
+51 -42
View File
@@ -2,50 +2,50 @@
* Shared types and helpers for the Agent composer (@workflow mentions + attachments).
*/
export type WorkflowKind = 'image' | 'video';
export type AttachmentKind = 'image' | 'video' | 'audio' | 'document';
export type WorkflowKind = "image" | "video";
export type AttachmentKind = "image" | "video" | "audio" | "document";
export interface WorkflowMention {
type: 'workflow';
id: number;
name: string;
kind: WorkflowKind;
type: "workflow";
id: number;
name: string;
kind: WorkflowKind;
}
export interface AgentAttachment {
path: string;
name: string;
kind: AttachmentKind;
path: string;
name: string;
kind: AttachmentKind;
}
export interface AgentComposerPayload {
content: string;
mentions: (WorkflowMention | SkillMention)[];
attachments: AgentAttachment[];
/** question/asked seq this message answers (question-card click). */
answer_to?: number;
content: string;
mentions: (WorkflowMention | SkillMention)[];
attachments: AgentAttachment[];
/** question/asked seq this message answers (question-card click). */
answer_to?: number;
}
export interface WorkflowOption {
id: number;
name: string;
kind: WorkflowKind;
description?: string | null;
is_enabled?: boolean;
id: number;
name: string;
kind: WorkflowKind;
description?: string | null;
is_enabled?: boolean;
}
export interface SkillOption {
name: string;
description: string;
tags: string[];
name: string;
description: string;
tags: string[];
}
/** A `/skill` command inserted as a chip. Serialized into the message's
* mentions payload so the backend can project it into the LLM context. */
export interface SkillMention {
type: 'skill';
name: string;
description: string;
type: "skill";
name: string;
description: string;
}
export const MAX_WORKFLOW_MENTIONS = 1;
@@ -53,27 +53,36 @@ export const MAX_WORKFLOW_MENTIONS = 1;
export const MENTION_LIMIT = 12;
/** Case-insensitive name filter of enabled workflows, capped for the typeahead. */
export function filterWorkflows(workflows: WorkflowOption[], query: string): WorkflowOption[] {
const q = query.trim().toLowerCase();
const enabled = workflows.filter((w) => w.is_enabled !== false);
const matched = q
? enabled.filter((w) => w.name.toLowerCase().includes(q))
: enabled;
return matched.slice(0, MENTION_LIMIT);
export function filterWorkflows(
workflows: WorkflowOption[],
query: string,
): WorkflowOption[] {
const q = query.trim().toLowerCase();
const enabled = workflows.filter((w) => w.is_enabled !== false);
const matched = q
? enabled.filter((w) => w.name.toLowerCase().includes(q))
: enabled;
return matched.slice(0, MENTION_LIMIT);
}
/** Case-insensitive filter of skills for the `/` typeahead (name + description). */
export function filterSkills(skills: SkillOption[], query: string): SkillOption[] {
const q = query.trim().toLowerCase();
const matched = q
? skills.filter(
(s) =>
s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q),
)
: skills;
return matched.slice(0, MENTION_LIMIT);
export function filterSkills(
skills: SkillOption[],
query: string,
): SkillOption[] {
const q = query.trim().toLowerCase();
const matched = q
? skills.filter(
(s) =>
s.name.toLowerCase().includes(q) ||
s.description.toLowerCase().includes(q),
)
: skills;
return matched.slice(0, MENTION_LIMIT);
}
export function payloadIsEmpty(p: AgentComposerPayload): boolean {
return !p.content.trim() && p.mentions.length === 0 && p.attachments.length === 0;
return (
!p.content.trim() && p.mentions.length === 0 && p.attachments.length === 0
);
}
+13 -11
View File
@@ -7,28 +7,30 @@
* buttons stay dumb and the wording never drifts out of sync.
*/
export type AgentTaskKind = 'story' | 'script';
export type AgentTaskKind = "story" | "script";
/** The pre-filled composer prompt for each task. */
export const AGENT_TASK_PROMPTS: Record<AgentTaskKind, string> = {
story:
'Draft the storyline for this project — generate the beats, characters, environments, and any misc. items from the story idea.',
script:
'Regenerate the full script for this project — turn the storyline into ordered scenes with action and dialogue.',
story:
"Draft the storyline for this project — generate the beats, characters, environments, and any misc. items from the story idea.",
script:
"Regenerate the full script for this project — turn the storyline into ordered scenes with action and dialogue.",
};
/** i18n key for the auto-created session's sidebar title. */
export const AGENT_TASK_TITLE_KEYS: Record<AgentTaskKind, string> = {
story: 'agent.task.story',
script: 'agent.task.script',
story: "agent.task.story",
script: "agent.task.script",
};
export function isAgentTaskKind(value: string | null | undefined): value is AgentTaskKind {
return value === 'story' || value === 'script';
export function isAgentTaskKind(
value: string | null | undefined,
): value is AgentTaskKind {
return value === "story" || value === "script";
}
/** Build the /agents deep-link URL for a project-scoped task. */
export function agentDeepLink(projectId: number, task: AgentTaskKind): string {
const params = new URLSearchParams({ project: String(projectId), task });
return `/agents?${params.toString()}`;
const params = new URLSearchParams({ project: String(projectId), task });
return `/agents?${params.toString()}`;
}
+1264 -654
View File
File diff suppressed because it is too large Load Diff
+41 -31
View File
@@ -1,50 +1,60 @@
/** Shared shape for Omni / video ref pickers. */
export type AssetGroup = 'character' | 'location' | 'item' | 'upload' | 'clip' | 'shot';
export type AssetGroup =
"character" | "location" | "item" | "upload" | "clip" | "shot";
export interface AssetOption {
label: string;
path: string;
kind?: 'image' | 'video' | 'audio';
group?: AssetGroup;
label: string;
path: string;
kind?: "image" | "video" | "audio";
group?: AssetGroup;
}
const GROUP_FROM_SUFFIX: Array<{ re: RegExp; group: AssetGroup }> = [
{ re: /\s·\s*sheet$/i, group: 'character' },
{ re: /\s·\s*environment$/i, group: 'location' },
{ re: /\s·\s*item$/i, group: 'item' },
{ re: /\s·\s*upload$/i, group: 'upload' },
{ re: /\s·\s*blockout(\s+clip)?$/i, group: 'shot' },
{ re: /^clip\s*#/i, group: 'clip' },
{ re: /\s·\s*sheet$/i, group: "character" },
{ re: /\s·\s*environment$/i, group: "location" },
{ re: /\s·\s*item$/i, group: "item" },
{ re: /\s·\s*upload$/i, group: "upload" },
{ re: /\s·\s*blockout(\s+clip)?$/i, group: "shot" },
{ re: /^clip\s*#/i, group: "clip" },
];
export function assetGroup(opt: AssetOption): AssetGroup {
if (opt.group) return opt.group;
for (const { re, group } of GROUP_FROM_SUFFIX) {
if (re.test(opt.label)) return group;
}
return 'upload';
if (opt.group) return opt.group;
for (const { re, group } of GROUP_FROM_SUFFIX) {
if (re.test(opt.label)) return group;
}
return "upload";
}
/** Strip the " · sheet" style suffix so the tab already names the type. */
export function assetDisplayName(opt: AssetOption): string {
return opt.label.replace(/\s·\s*(sheet|environment|item|upload|blockout(\s+clip)?)\s*$/i, '').trim() || opt.label;
return (
opt.label
.replace(
/\s·\s*(sheet|environment|item|upload|blockout(\s+clip)?)\s*$/i,
"",
)
.trim() || opt.label
);
}
export const ASSET_GROUP_TABS: Array<{ id: AssetGroup; label: string }> = [
{ id: 'character', label: 'Characters' },
{ id: 'location', label: 'Environments' },
{ id: 'item', label: 'Misc. Items' },
{ id: 'shot', label: 'From Build Scene' },
{ id: 'clip', label: 'Clips' },
{ id: 'upload', label: 'Uploads' },
{ id: "character", label: "Characters" },
{ id: "location", label: "Environments" },
{ id: "item", label: "Misc. Items" },
{ id: "shot", label: "From Build Scene" },
{ id: "clip", label: "Clips" },
{ id: "upload", label: "Uploads" },
];
export function tabsForMediaKind(kind: string | undefined): Array<{ id: AssetGroup; label: string }> {
const ids: AssetGroup[] =
kind === 'video'
? ['clip', 'upload']
: kind === 'audio'
? ['upload']
: ['character', 'location', 'item', 'shot', 'upload'];
return ASSET_GROUP_TABS.filter((t) => ids.includes(t.id));
export function tabsForMediaKind(
kind: string | undefined,
): Array<{ id: AssetGroup; label: string }> {
const ids: AssetGroup[] =
kind === "video"
? ["clip", "upload"]
: kind === "audio"
? ["upload"]
: ["character", "location", "item", "shot", "upload"];
return ASSET_GROUP_TABS.filter((t) => ids.includes(t.id));
}
+11 -14
View File
@@ -47,14 +47,12 @@
</header>
<div class="media">
{#if videoSrc}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<button
type="button"
class="media-btn"
onclick={openPreview}
title={t('canvas.openPreview')}
>
<!-- svelte-ignore a11y_media_has_caption -->
<video
class="media-el"
src={videoSrc}
@@ -64,7 +62,6 @@
></video>
</button>
{:else if mediaSrc}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<button
type="button"
class="media-btn"
@@ -98,8 +95,8 @@
position: relative;
}
.artifact-node.running {
border-color: rgba(125, 211, 252, 0.6);
box-shadow: 0 0 18px rgba(125, 211, 252, 0.25);
border-color: var(--run);
box-shadow: 0 0 18px var(--run-soft);
}
:global(.svelte-flow__node.selected) .artifact-node {
border-color: var(--accent);
@@ -117,9 +114,9 @@
font-size: 9px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #bae6fd;
background: rgba(12, 74, 110, 0.55);
border: 1px solid rgba(125, 211, 252, 0.35);
color: var(--text-2);
background: var(--bg-4);
border: 1px solid var(--line-2);
border-radius: 999px;
padding: 2px 6px;
flex-shrink: 0;
@@ -141,12 +138,12 @@
flex-shrink: 0;
}
.running-badge {
color: #bae6fd;
background: rgba(12, 74, 110, 0.6);
color: var(--run);
background: var(--run-soft);
}
.failed-badge {
color: #fecaca;
background: rgba(127, 29, 29, 0.5);
color: var(--err);
background: var(--err-soft);
}
.play-badge {
font-size: 9px;
@@ -205,9 +202,9 @@
font-weight: 600;
letter-spacing: 0.04em;
text-transform: lowercase;
color: var(--text-muted);
color: var(--on-media);
pointer-events: none;
background: rgba(0, 0, 0, 0.55);
background: var(--media-scrim);
padding: 1px 5px;
border-radius: 999px;
}
@@ -76,7 +76,6 @@
<SafeMedia class="media-el" src={posterSrc} alt={data.title} kind="image" controls={false} />
{:else if videoSrc}
<!-- No poster available: #t=0.1 first-frame thumbnail. -->
<!-- svelte-ignore a11y_media_has_caption -->
<video
class="media-el"
src={videoSrc}
@@ -108,7 +107,7 @@
transition: border-color 0.15s;
}
.entity-node:hover {
border-color: #3a3a44;
border-color: var(--line-2);
}
/* Svelte Flow marks the selected node wrapper with .selected */
:global(.svelte-flow__node.selected) .entity-node {
@@ -176,9 +175,9 @@
font-weight: 600;
letter-spacing: 0.04em;
text-transform: lowercase;
color: var(--text-muted);
color: var(--on-media);
pointer-events: none;
background: rgba(0, 0, 0, 0.55);
background: var(--media-scrim);
padding: 1px 5px;
border-radius: 999px;
}
-13
View File
@@ -1,13 +0,0 @@
/* Canvas v2.1 scoped visual tokens (spec v2.0 §2) — canvas surfaces only.
The global --accent stays untouched for v1 pages. */
.canvas-root {
--accent: #4f46a5;
--accent-hover: #6157c9;
--accent-glow: rgba(99, 102, 241, 0.3);
--edge: #7dd3fc;
/* Edge-chip surfaces (LinkEdge label) — fallbacks match the dark theme. */
--panel: #17171f;
--panel-border: #2a2a35;
--fg: #f4f4f5;
--fg-dim: #a1a1aa;
}
+102 -96
View File
@@ -12,24 +12,25 @@
* control → compact pills (resolution, duration, seed)
* advanced → hidden inside an "Advanced" popover
*/
import type { ComfyDynamicInput } from './types';
import { normalizeInputRole } from './parser';
import type { ComfyDynamicInput } from "./types";
import { normalizeInputRole } from "./parser";
export type InputZone = 'composer' | 'media' | 'control' | 'advanced' | 'hidden';
export type InputZone =
"composer" | "media" | "control" | "advanced" | "hidden";
export type OmniWidget =
| 'promptTextarea'
| 'negativeTextarea'
| 'uploadTile'
| 'assetTile'
| 'resolutionPill'
| 'numberPill'
| 'textPill';
| "promptTextarea"
| "negativeTextarea"
| "uploadTile"
| "assetTile"
| "resolutionPill"
| "numberPill"
| "textPill";
export interface ClassifiedInput {
input: ComfyDynamicInput;
zone: InputZone;
widget: OmniWidget;
input: ComfyDynamicInput;
zone: InputZone;
widget: OmniWidget;
}
/**
@@ -39,49 +40,49 @@ export interface ClassifiedInput {
* zone. Unknown roles fall to `advanced` so no field is ever hidden or lost.
*/
export function classifyInput(inp: ComfyDynamicInput): ClassifiedInput {
const role = normalizeInputRole(inp.role);
const role = normalizeInputRole(inp.role);
// --- Composer ---
if (role === 'prompt' || role === 'positive') {
return { input: inp, zone: 'composer', widget: 'promptTextarea' };
}
if (role === 'negative') {
return { input: inp, zone: 'composer', widget: 'negativeTextarea' };
}
// --- Composer ---
if (role === "prompt" || role === "positive") {
return { input: inp, zone: "composer", widget: "promptTextarea" };
}
if (role === "negative") {
return { input: inp, zone: "composer", widget: "negativeTextarea" };
}
// --- Media tray ---
if (
role === 'character' ||
role === 'location' ||
role === 'image' ||
role === 'video' ||
role === 'audio'
) {
const isMediaKind =
inp.kind === 'image' ||
inp.kind === 'image_url' ||
inp.kind === 'audio' ||
inp.kind === 'video';
return {
input: inp,
zone: 'media',
widget: isMediaKind ? 'uploadTile' : 'assetTile',
};
}
// --- Media tray ---
if (
role === "character" ||
role === "location" ||
role === "image" ||
role === "video" ||
role === "audio"
) {
const isMediaKind =
inp.kind === "image" ||
inp.kind === "image_url" ||
inp.kind === "audio" ||
inp.kind === "video";
return {
input: inp,
zone: "media",
widget: isMediaKind ? "uploadTile" : "assetTile",
};
}
// --- Control bar ---
if (role === 'width' || role === 'height') {
return { input: inp, zone: 'control', widget: 'resolutionPill' };
}
if (role === 'duration' || role === 'seed') {
return { input: inp, zone: 'control', widget: 'numberPill' };
}
// --- Control bar ---
if (role === "width" || role === "height") {
return { input: inp, zone: "control", widget: "resolutionPill" };
}
if (role === "duration" || role === "seed") {
return { input: inp, zone: "control", widget: "numberPill" };
}
// --- Advanced popover ---
if (inp.kind === 'number') {
return { input: inp, zone: 'advanced', widget: 'numberPill' };
}
return { input: inp, zone: 'advanced', widget: 'textPill' };
// --- Advanced popover ---
if (inp.kind === "number") {
return { input: inp, zone: "advanced", widget: "numberPill" };
}
return { input: inp, zone: "advanced", widget: "textPill" };
}
/**
@@ -92,57 +93,62 @@ export function classifyInput(inp: ComfyDynamicInput): ClassifiedInput {
* nodeIds on change.
*/
export function classifyAll(inputs: ComfyDynamicInput[]): {
prompt: ClassifiedInput | null;
negative: ClassifiedInput | null;
media: ClassifiedInput[];
control: ClassifiedInput[];
advanced: ClassifiedInput[];
/** Non-null when both width AND height are present → merge into one pill. */
resolutionPair: { width: ClassifiedInput; height: ClassifiedInput } | null;
prompt: ClassifiedInput | null;
negative: ClassifiedInput | null;
media: ClassifiedInput[];
control: ClassifiedInput[];
advanced: ClassifiedInput[];
/** Non-null when both width AND height are present → merge into one pill. */
resolutionPair: { width: ClassifiedInput; height: ClassifiedInput } | null;
} {
const classified = inputs.map(classifyInput);
const classified = inputs.map(classifyInput);
const prompt = classified.find((c) => c.widget === 'promptTextarea') ?? null;
const negative = classified.find((c) => c.widget === 'negativeTextarea') ?? null;
const media = classified.filter((c) => c.zone === 'media');
const advanced = classified.filter((c) => c.zone === 'advanced');
const prompt = classified.find((c) => c.widget === "promptTextarea") ?? null;
const negative =
classified.find((c) => c.widget === "negativeTextarea") ?? null;
const media = classified.filter((c) => c.zone === "media");
const advanced = classified.filter((c) => c.zone === "advanced");
// Separate resolution from other control inputs
const controlNonRes = classified.filter(
(c) => c.zone === 'control' && c.widget !== 'resolutionPill',
);
const resInputs = classified.filter((c) => c.widget === 'resolutionPill');
// Separate resolution from other control inputs
const controlNonRes = classified.filter(
(c) => c.zone === "control" && c.widget !== "resolutionPill",
);
const resInputs = classified.filter((c) => c.widget === "resolutionPill");
// Detect width + height pair
const wInput = resInputs.find((c) => normalizeInputRole(c.input.role) === 'width');
const hInput = resInputs.find((c) => normalizeInputRole(c.input.role) === 'height');
const resolutionPair =
wInput && hInput ? { width: wInput, height: hInput } : null;
// Detect width + height pair
const wInput = resInputs.find(
(c) => normalizeInputRole(c.input.role) === "width",
);
const hInput = resInputs.find(
(c) => normalizeInputRole(c.input.role) === "height",
);
const resolutionPair =
wInput && hInput ? { width: wInput, height: hInput } : null;
// If only one of width/height exists, treat it as a standalone control number pill
const standaloneRes = resolutionPair ? [] : resInputs;
// If only one of width/height exists, treat it as a standalone control number pill
const standaloneRes = resolutionPair ? [] : resInputs;
return {
prompt,
negative,
media,
control: [...controlNonRes, ...standaloneRes],
advanced,
resolutionPair,
};
return {
prompt,
negative,
media,
control: [...controlNonRes, ...standaloneRes],
advanced,
resolutionPair,
};
}
// ── Resolution presets ────────────────────────────────────────────────────────
export interface ResolutionPreset {
label: string;
width: number;
height: number;
label: string;
width: number;
height: number;
}
export const RESOLUTION_PRESETS: ResolutionPreset[] = [
{ label: '720p', width: 1280, height: 720 },
{ label: '1080p', width: 1920, height: 1080 },
{ label: "720p", width: 1280, height: 720 },
{ label: "1080p", width: 1920, height: 1080 },
];
/**
@@ -150,12 +156,12 @@ export const RESOLUTION_PRESETS: ResolutionPreset[] = [
* (or return null for custom / unset values).
*/
export function resolutionLabel(
width: number | string | undefined,
height: number | string | undefined,
width: number | string | undefined,
height: number | string | undefined,
): string | null {
const w = Number(width);
const h = Number(height);
if (!w || !h) return null;
const match = RESOLUTION_PRESETS.find((p) => p.width === w && p.height === h);
return match?.label ?? `${w}×${h}`;
const w = Number(width);
const h = Number(height);
if (!w || !h) return null;
const match = RESOLUTION_PRESETS.find((p) => p.width === w && p.height === h);
return match?.label ?? `${w}×${h}`;
}
+72
View File
@@ -0,0 +1,72 @@
import { t } from "$lib/i18n.svelte";
/** Studio image slots (ref-upload-spec §3): PNG / JPG / WebP, ≤ 20MB each. */
export const IMAGE_SLOT_ACCEPT =
".png,.jpg,.jpeg,.webp,image/png,image/jpeg,image/webp";
export const IMAGE_SLOT_MAX_BYTES = 20 * 1024 * 1024;
const IMAGE_EXTS = [".png", ".jpg", ".jpeg", ".webp"];
/** Reference video slots (HEL-643): MP4 / WebM / MOV, ≤ 500MB (upload cap). */
export const VIDEO_SLOT_ACCEPT =
"video/mp4,video/webm,video/quicktime,.mp4,.webm,.mov";
export const VIDEO_SLOT_MAX_BYTES = 500 * 1024 * 1024;
const VIDEO_EXTS = [".mp4", ".webm", ".mov"];
/** Reference audio slots (HEL-643): MP3 / WAV / M4A, ≤ 100MB. */
export const AUDIO_SLOT_ACCEPT =
"audio/mpeg,audio/wav,audio/mp4,.mp3,.wav,.m4a";
export const AUDIO_SLOT_MAX_BYTES = 100 * 1024 * 1024;
const AUDIO_EXTS = [".mp3", ".wav", ".m4a"];
export type MediaSlotKind = "image" | "video" | "audio";
export function mediaSlotAccept(kind: MediaSlotKind): string {
if (kind === "video") return VIDEO_SLOT_ACCEPT;
if (kind === "audio") return AUDIO_SLOT_ACCEPT;
return IMAGE_SLOT_ACCEPT;
}
/** Why this file cannot fill a media slot of this kind (null = fine). */
export function mediaFileProblem(
kind: MediaSlotKind,
file: { name: string; size: number },
): string | null {
const dot = file.name.lastIndexOf(".");
const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : "";
const max =
kind === "video"
? VIDEO_SLOT_MAX_BYTES
: kind === "audio"
? AUDIO_SLOT_MAX_BYTES
: IMAGE_SLOT_MAX_BYTES;
if (kind === "video" && !VIDEO_EXTS.includes(ext))
return t("studio.errVideoFormat", { name: file.name });
if (kind === "audio" && !AUDIO_EXTS.includes(ext))
return t("studio.errAudioFormat", { name: file.name });
if (kind === "image" && !IMAGE_EXTS.includes(ext))
return t("studio.errImageFormat", { name: file.name });
if (file.size > max)
return kind === "image"
? t("studio.errImageSize", { name: file.name })
: t("studio.errMediaSize", { name: file.name });
return null;
}
/** Why this file cannot fill an image slot (null = fine). */
export function imageFileProblem(file: {
name: string;
size: number;
}): string | null {
return mediaFileProblem("image", file);
}
export function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
}
/** Display name of an uploaded path (strips the uuid8- prefix the upload endpoint writes). */
export function uploadBaseName(p: string): string {
const raw = p.split(/[/\\]/).pop() ?? p;
return /^[0-9a-f]{8}-/.test(raw) ? raw.slice(9) : raw;
}
+163 -113
View File
@@ -1,142 +1,192 @@
import type { ComfyDynamicInput, ComfyDynamicOutput, WorkflowNode } from './types';
import type {
ComfyDynamicInput,
ComfyDynamicOutput,
WorkflowNode,
} from "./types";
const TEXT_AREA = new Set([
'CLIPTextEncode',
'Note',
'ShowText',
'ImpactWildcardProcessor',
'PrimitiveStringMultiline',
'PrimitiveString',
"CLIPTextEncode",
"Note",
"ShowText",
"ImpactWildcardProcessor",
"PrimitiveStringMultiline",
"PrimitiveString",
]);
const NUMBER = new Set([
"INT",
"FLOAT",
"PrimitiveInt",
"PrimitiveFloat",
"KSampler",
"KSamplerAdvanced",
]);
const IMAGE = new Set([
"LoadImage",
"LoadImageMask",
"ImageLoader",
"ETN_LoadImageBase64",
]);
const IMAGE_URL = new Set(["Load Image From Url (mtb)"]);
const AUDIO = new Set(["LoadAudio", "VHS_LoadAudio"]);
const VIDEO = new Set(["LoadVideo", "VHS_LoadVideo", "VHS_LoadVideoPath"]);
const VIDEO_OUT = new Set([
"VHS_VideoCombine",
"SaveVideo",
"VideoOutput",
"AnimateDiffCombine",
]);
const IMAGE_OUT = new Set([
"SaveImage",
"PreviewImage",
"SaveImageWebsocket",
"ETN_SendImageWebSocket",
]);
const NUMBER = new Set(['INT', 'FLOAT', 'PrimitiveInt', 'PrimitiveFloat', 'KSampler', 'KSamplerAdvanced']);
const IMAGE = new Set(['LoadImage', 'ImageLoader', 'ETN_LoadImageBase64']);
const IMAGE_URL = new Set(['Load Image From Url (mtb)']);
const AUDIO = new Set(['LoadAudio', 'VHS_LoadAudio']);
const VIDEO = new Set(['LoadVideo', 'VHS_LoadVideo', 'VHS_LoadVideoPath']);
const VIDEO_OUT = new Set(['VHS_VideoCombine', 'SaveVideo', 'VideoOutput', 'AnimateDiffCombine']);
const IMAGE_OUT = new Set(['SaveImage', 'PreviewImage', 'SaveImageWebsocket', 'ETN_SendImageWebSocket']);
/** Matches (Input), (Input:prompt), (Output:image), … */
const TITLE_TAG_RE = /\((Input|Output)(?::([a-zA-Z0-9_-]+))?\)/i;
const INPUT_ROLE_ALIASES: Record<string, string[]> = {
prompt: ['prompt', 'positive'],
negative: ['negative', 'neg'],
width: ['width', 'w'],
height: ['height', 'h'],
character: ['character', 'char', 'portrait', 'sheet', 'face', 'ref'],
location: ['location', 'loc', 'environment', 'env', 'background', 'scene'],
image: ['image', 'img'],
video: ['video', 'vid'],
audio: ['audio', 'sound', 'sfx'],
seed: ['seed'],
duration: ['duration', 'dur', 'length', 'seconds'],
prompt: ["prompt", "positive"],
negative: ["negative", "neg"],
width: ["width", "w"],
height: ["height", "h"],
character: ["character", "char", "portrait", "sheet", "face", "ref"],
location: ["location", "loc", "environment", "env", "background", "scene"],
image: ["image", "img"],
video: ["video", "vid"],
audio: ["audio", "sound", "sfx"],
seed: ["seed"],
duration: ["duration", "dur", "length", "seconds"],
};
const OUTPUT_ROLE_ALIASES: Record<string, string[]> = {
image: ['image', 'img'],
video: ['video', 'vid'],
image: ["image", "img"],
video: ["video", "vid"],
};
export function parseTitleTag(title: string): {
kind: 'input' | 'output' | null;
role: string | null;
label: string;
kind: "input" | "output" | null;
role: string | null;
label: string;
} {
if (!title) return { kind: null, role: null, label: '' };
const match = TITLE_TAG_RE.exec(title);
if (!match) return { kind: null, role: null, label: title.trim() };
const kind = match[1].toLowerCase() as 'input' | 'output';
const role = match[2] ? match[2].toLowerCase() : null;
const stripped = title.replace(TITLE_TAG_RE, '').trim();
const label = stripped || role || kind;
return { kind, role, label };
if (!title) return { kind: null, role: null, label: "" };
const match = TITLE_TAG_RE.exec(title);
if (!match) return { kind: null, role: null, label: title.trim() };
const kind = match[1].toLowerCase() as "input" | "output";
const role = match[2] ? match[2].toLowerCase() : null;
const stripped = title.replace(TITLE_TAG_RE, "").trim();
const label = stripped || role || kind;
return { kind, role, label };
}
export function normalizeInputRole(role: string | null | undefined): string | null {
if (!role) return null;
const r = role.toLowerCase().trim();
for (const [canonical, aliases] of Object.entries(INPUT_ROLE_ALIASES)) {
if (r === canonical || aliases.includes(r)) return canonical;
}
return r;
export function normalizeInputRole(
role: string | null | undefined,
): string | null {
if (!role) return null;
const r = role.toLowerCase().trim();
for (const [canonical, aliases] of Object.entries(INPUT_ROLE_ALIASES)) {
if (r === canonical || aliases.includes(r)) return canonical;
}
return r;
}
export function normalizeOutputRole(role: string | null | undefined): string | null {
if (!role) return null;
const r = role.toLowerCase().trim();
for (const [canonical, aliases] of Object.entries(OUTPUT_ROLE_ALIASES)) {
if (r === canonical || aliases.includes(r)) return canonical;
}
return r;
export function normalizeOutputRole(
role: string | null | undefined,
): string | null {
if (!role) return null;
const r = role.toLowerCase().trim();
for (const [canonical, aliases] of Object.entries(OUTPUT_ROLE_ALIASES)) {
if (r === canonical || aliases.includes(r)) return canonical;
}
return r;
}
function classToInputKind(classType: string): ComfyDynamicInput['kind'] {
if (IMAGE.has(classType)) return 'image';
if (IMAGE_URL.has(classType)) return 'image_url';
if (AUDIO.has(classType)) return 'audio';
if (VIDEO.has(classType)) return 'video';
if (NUMBER.has(classType)) return 'number';
if (TEXT_AREA.has(classType)) return 'textarea';
const lower = classType.toLowerCase();
if (lower.includes('video')) return 'video';
if (lower.includes('audio')) return 'audio';
if (lower.includes('image') || lower.includes('load')) return 'image';
if (lower.includes('int') || lower.includes('float') || lower.includes('seed')) return 'number';
if (lower.includes('text') || lower.includes('clip') || lower.includes('prompt')) return 'textarea';
return 'text';
function classToInputKind(classType: string): ComfyDynamicInput["kind"] {
if (IMAGE.has(classType)) return "image";
if (IMAGE_URL.has(classType)) return "image_url";
if (AUDIO.has(classType)) return "audio";
if (VIDEO.has(classType)) return "video";
if (NUMBER.has(classType)) return "number";
if (TEXT_AREA.has(classType)) return "textarea";
const lower = classType.toLowerCase();
if (lower.includes("video")) return "video";
if (lower.includes("audio")) return "audio";
if (lower.includes("image") || lower.includes("load")) return "image";
if (
lower.includes("int") ||
lower.includes("float") ||
lower.includes("seed")
)
return "number";
if (
lower.includes("text") ||
lower.includes("clip") ||
lower.includes("prompt")
)
return "textarea";
return "text";
}
function extractDefault(node: WorkflowNode): string | number | undefined {
const { inputs, class_type } = node;
if (IMAGE.has(class_type) || AUDIO.has(class_type) || VIDEO.has(class_type)) return undefined;
if (typeof inputs.text === 'string') return inputs.text;
if (typeof inputs.value === 'string' || typeof inputs.value === 'number') return inputs.value;
if (typeof inputs.int === 'number') return inputs.int;
if (typeof inputs.float === 'number') return inputs.float;
return undefined;
const { inputs, class_type } = node;
if (IMAGE.has(class_type) || AUDIO.has(class_type) || VIDEO.has(class_type))
return undefined;
if (typeof inputs.text === "string") return inputs.text;
if (typeof inputs.value === "string" || typeof inputs.value === "number")
return inputs.value;
if (typeof inputs.int === "number") return inputs.int;
if (typeof inputs.float === "number") return inputs.float;
return undefined;
}
export function parseDynamicInputs(workflow: Record<string, WorkflowNode>): ComfyDynamicInput[] {
const results: ComfyDynamicInput[] = [];
for (const [nodeId, node] of Object.entries(workflow)) {
const title = node._meta?.title ?? '';
const { kind, role, label } = parseTitleTag(title);
if (kind !== 'input') continue;
results.push({
nodeId,
label: label || node.class_type,
role: normalizeInputRole(role),
kind: classToInputKind(node.class_type),
defaultValue: extractDefault(node),
required: true,
});
}
return results;
export function parseDynamicInputs(
workflow: Record<string, WorkflowNode>,
): ComfyDynamicInput[] {
const results: ComfyDynamicInput[] = [];
for (const [nodeId, node] of Object.entries(workflow)) {
const title = node._meta?.title ?? "";
const { kind, role, label } = parseTitleTag(title);
if (kind !== "input") continue;
results.push({
nodeId,
label: label || node.class_type,
role: normalizeInputRole(role),
kind: classToInputKind(node.class_type),
defaultValue: extractDefault(node),
required: true,
});
}
return results;
}
export function parseDynamicOutputs(workflow: Record<string, WorkflowNode>): ComfyDynamicOutput[] {
const results: ComfyDynamicOutput[] = [];
for (const [nodeId, node] of Object.entries(workflow)) {
const title = node._meta?.title ?? '';
const { kind, role, label } = parseTitleTag(title);
if (kind !== 'output') continue;
const canon = normalizeOutputRole(role);
let outKind: ComfyDynamicOutput['kind'] = 'other';
if (canon === 'video' || VIDEO_OUT.has(node.class_type) || node.class_type.toLowerCase().includes('video'))
outKind = 'video';
else if (
canon === 'image' ||
IMAGE_OUT.has(node.class_type) ||
node.class_type.toLowerCase().includes('image')
)
outKind = 'image';
results.push({
nodeId,
label: label || node.class_type,
role: canon,
kind: outKind,
});
}
return results;
export function parseDynamicOutputs(
workflow: Record<string, WorkflowNode>,
): ComfyDynamicOutput[] {
const results: ComfyDynamicOutput[] = [];
for (const [nodeId, node] of Object.entries(workflow)) {
const title = node._meta?.title ?? "";
const { kind, role, label } = parseTitleTag(title);
if (kind !== "output") continue;
const canon = normalizeOutputRole(role);
let outKind: ComfyDynamicOutput["kind"] = "other";
if (
canon === "video" ||
VIDEO_OUT.has(node.class_type) ||
node.class_type.toLowerCase().includes("video")
)
outKind = "video";
else if (
canon === "image" ||
IMAGE_OUT.has(node.class_type) ||
node.class_type.toLowerCase().includes("image")
)
outKind = "image";
results.push({
nodeId,
label: label || node.class_type,
role: canon,
kind: outKind,
});
}
return results;
}
+36 -31
View File
@@ -1,50 +1,55 @@
import type { ComfyDynamicInput } from './types';
import { normalizeInputRole } from './parser';
import type { ComfyDynamicInput } from "./types";
import { normalizeInputRole } from "./parser";
const PROMPT_ROLES = new Set(['prompt', 'positive']);
const PROMPT_ROLES = new Set(["prompt", "positive"]);
function roleOf(inp: ComfyDynamicInput): string | null {
return normalizeInputRole(inp.role ?? null);
return normalizeInputRole(inp.role ?? null);
}
/** Positive prompt input — prefers (Input:prompt), legacy label fallback. */
export function isPromptLikeInput(inp: ComfyDynamicInput): boolean {
const role = roleOf(inp);
if (role && PROMPT_ROLES.has(role)) return true;
if (role) return false; // other roles are never "the" prompt
if (inp.kind !== 'text' && inp.kind !== 'textarea') return false;
const label = (inp.label || '').toLowerCase();
if (label.includes('negative')) return false;
return (
label.includes('prompt') ||
label.includes('positive') ||
label.includes('text') ||
inp.kind === 'textarea'
);
const role = roleOf(inp);
if (role && PROMPT_ROLES.has(role)) return true;
if (role) return false; // other roles are never "the" prompt
if (inp.kind !== "text" && inp.kind !== "textarea") return false;
const label = (inp.label || "").toLowerCase();
if (label.includes("negative")) return false;
return (
label.includes("prompt") ||
label.includes("positive") ||
label.includes("text") ||
inp.kind === "textarea"
);
}
/** Drop prompt-role fields from shared workflow forms (entity Image prompt owns those). */
export function withoutPromptInputs(inputs: ComfyDynamicInput[] | undefined | null): ComfyDynamicInput[] {
if (!inputs?.length) return [];
return inputs.filter((inp) => !isPromptLikeInput(inp));
export function withoutPromptInputs(
inputs: ComfyDynamicInput[] | undefined | null,
): ComfyDynamicInput[] {
if (!inputs?.length) return [];
return inputs.filter((inp) => !isPromptLikeInput(inp));
}
/** True if this workflow schema has a prompt-role (or legacy prompt-like) (Input). */
export function workflowHasPromptInput(inputs: ComfyDynamicInput[] | undefined | null): boolean {
if (!inputs?.length) return false;
return inputs.some(isPromptLikeInput);
export function workflowHasPromptInput(
inputs: ComfyDynamicInput[] | undefined | null,
): boolean {
if (!inputs?.length) return false;
return inputs.some(isPromptLikeInput);
}
/** Strip blank strings so empty fields cannot wipe smart-fill. */
export function compactInputValues(
values: Record<string, unknown> | undefined | null,
values: Record<string, unknown> | undefined | null,
): Record<string, unknown> {
if (!values) return {};
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(values)) {
if (v === null || v === undefined) continue;
if (typeof v === 'string' && !v.trim()) continue;
out[k] = v;
}
return out;
if (!values) return {};
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(values)) {
if (v === null || v === undefined) continue;
if (typeof v === "string" && !v.trim()) continue;
if (Array.isArray(v) && !v.length) continue;
out[k] = v;
}
return out;
}
+44
View File
@@ -0,0 +1,44 @@
/**
* ComfyUI ResolutionSelector (comfy_extras/nodes_resolution.py) — same
* ratios and rounding, so the size shown is the size ComfyUI renders.
*/
const RATIOS: Record<string, [number, number]> = {
"1:1 (Square)": [1, 1],
"2:3 (Portrait Photo)": [2, 3],
"3:2 (Photo)": [3, 2],
"3:4 (Portrait Standard)": [3, 4],
"4:3 (Standard)": [4, 3],
"9:16 (Portrait Widescreen)": [9, 16],
"16:9 (Widescreen)": [16, 9],
"21:9 (Ultrawide)": [21, 9],
};
/** Python's round(): halves go to the even neighbour. */
function roundHalfEven(x: number): number {
const f = Math.floor(x);
const diff = x - f;
if (diff > 0.5) return f + 1;
if (diff < 0.5) return f;
return f % 2 === 0 ? f : f + 1;
}
export function resolutionSelectorSize(
aspectRatio: string,
megapixels: number,
multiple: number,
): { width: number; height: number } | null {
const ratio = RATIOS[aspectRatio];
if (!ratio || !Number.isFinite(megapixels) || megapixels <= 0) return null;
const [w, h] = ratio;
const m = Math.max(1, Math.floor(multiple) || 8);
const scale = Math.sqrt((megapixels * 1024 * 1024) / (w * h));
return {
width: roundHalfEven((w * scale) / m) * m,
height: roundHalfEven((h * scale) / m) * m,
};
}
/** "16:9 (Widescreen)" → "16:9". */
export function ratioShort(option: string): string {
return option.split(" ")[0] ?? option;
}
+359 -86
View File
@@ -1,111 +1,384 @@
export type ComfyInputKind = 'text' | 'textarea' | 'number' | 'image' | 'image_url' | 'audio' | 'video';
export type ComfyOutputKind = 'image' | 'video' | 'other';
export type ComfyInputKind =
| "text"
| "textarea"
| "number"
| "select"
| "image"
| "image_url"
| "audio"
| "video"
/** Ordered list of images for one Autogrow input (e.g. H3 ref_images, min…max). */
| "image_list"
/** Ordered lists of videos / audios for Autogrow inputs (HEL-643). */
| "video_list"
| "audio_list";
export type ComfyOutputKind = "image" | "video" | "other";
/** Structural finding on an imported workflow; `error` blocks generation. */
export interface WorkflowWarning {
level: "error" | "warning";
code: string;
node_id: string;
message: string;
}
export interface ComfyDynamicInput {
nodeId: string;
label: string;
/** Role from title tag, e.g. (Input:prompt) → "prompt". Null for plain (Input). */
role?: string | null;
kind: ComfyInputKind;
defaultValue?: string | number;
required: boolean;
nodeId: string;
label: string;
/** Role from title tag, e.g. (Input:prompt) → "prompt". Null for plain (Input). */
role?: string | null;
kind: ComfyInputKind;
defaultValue?: string | number;
required: boolean;
/** Inferred from node classes/wiring — the workflow carries no (Input) tags. */
auto?: boolean;
/** ComfyUI widget limits (number kind). */
min?: number;
max?: number;
step?: number;
integer?: boolean;
/** Allowed values (select kind). */
options?: string[];
/** On a ResolutionSelector aspect_ratio entry: its megapixels key and rounding multiple. */
resolution?: { megapixels: string; multiple: number };
/** Official usage-limits hint key for known media groups (HEL-643, e.g. 'minimax_h3_ref2va'). */
media_hint?: string;
}
export interface ComfyDynamicOutput {
nodeId: string;
label: string;
/** Role from title tag, e.g. (Output:image) → "image". */
role?: string | null;
kind: ComfyOutputKind;
nodeId: string;
label: string;
/** Role from title tag, e.g. (Output:image). */
role?: string | null;
/** Null while an unknown custom node awaits the user's media-type pick. */
kind: ComfyOutputKind | null;
/** Untagged detection: node class + which rule typed it. */
class_type?: string;
evidence?: "registry" | "class_name_match" | "unknown_node";
/** Form default for unknown candidates — never auto-applied. */
suggested_kind?: "image" | "video" | null;
}
/** One entry of a workflow's confirmed output mapping (HEL-638). */
export interface WorkflowOutputMapEntry {
nodeId: string;
kind: "image" | "video";
label?: string;
class_type?: string;
}
/** The five workflow usages the studio distinguishes (HEL-636). */
export type WorkflowUsage =
"text2image" | "image2image" | "text2video" | "image2video" | "ref2video";
export const WORKFLOW_USAGES: WorkflowUsage[] = [
"text2image",
"image2image",
"text2video",
"image2video",
"ref2video",
];
/** Which usages each generate page serves. */
export const USAGE_BY_KIND: Record<"image" | "video", WorkflowUsage[]> = {
image: ["text2image", "image2image"],
video: ["text2video", "image2video", "ref2video"],
};
/** One L2 connection-check finding against a live ComfyUI (HEL-643). */
export interface PreflightFinding {
level: "error" | "warn" | "info" | "ok";
code: string;
node_id?: string;
node_title?: string | null;
message: string;
hint?: string | null;
}
/** Result of checking a workflow against a target connection's /object_info. */
export interface WorkflowConnectionCheck {
endpoint_id: string | null;
endpoint_name: string | null;
base_url?: string | null;
/** False when any error-level finding exists (or the server was unreachable). */
ok: boolean;
/** False → 无法核查 (server down): honest, non-blocking. */
reachable: boolean;
findings: PreflightFinding[];
message?: string | null;
}
/** Slim live record of a workflow's latest test run (HEL-643). */
export interface WorkflowLastTest {
id: number;
status: string;
phase: string | null;
error: string | null;
created_at: string;
completed_at: string | null;
output_count: number;
}
/** Result of POST /api/workflows/analyze (import confirm step 1). */
export interface WorkflowAnalyzeResult {
inputs: ComfyDynamicInput[];
outputs: ComfyDynamicOutput[];
suggested_profile?: string;
warnings?: WorkflowWarning[];
usage: WorkflowUsage | null;
usage_ambiguous: boolean;
needs_mapping: boolean;
usage_problem: string | null;
usage_evidence: {
outputs: ComfyDynamicOutput[];
image_slots: Array<{
nodeId: string;
label: string;
kind: string;
role: string | null;
}>;
video_slots: Array<{
nodeId: string;
label: string;
kind: string;
role: string | null;
}>;
summary: string[];
};
usable_usage: WorkflowUsage[];
/** Present when analyze was called with a target endpoint (HEL-643 L2). */
connection_check?: WorkflowConnectionCheck;
}
export interface WorkflowNode {
class_type: string;
inputs: Record<string, unknown>;
_meta?: { title?: string };
class_type: string;
inputs: Record<string, unknown>;
_meta?: { title?: string };
}
export interface Workflow {
id: number;
name: string;
kind: 'image' | 'video';
workflow_json: Record<string, WorkflowNode>;
input_schema: ComfyDynamicInput[];
output_schema: ComfyDynamicOutput[];
description: string | null;
prompt_profile: string;
is_enabled: boolean;
/** Bound ComfyUI connection id (settings.comfyui_endpoints); null = active. */
endpoint_id?: string | null;
id: number;
name: string;
kind: "image" | "video";
/** Five-way usage (HEL-636); null = pending human confirmation. */
usage?: WorkflowUsage | null;
usage_source?: "auto" | "user" | null;
workflow_json: Record<string, WorkflowNode>;
input_schema: ComfyDynamicInput[];
output_schema: ComfyDynamicOutput[];
description: string | null;
prompt_profile: string;
is_enabled: boolean;
/** Bound ComfyUI connection id (settings.comfyui_endpoints); null = active. */
endpoint_id?: string | null;
warnings?: WorkflowWarning[];
/** HEL-638: confirmed output mapping ({nodeId, kind}); null/empty = pending. */
output_map?: WorkflowOutputMapEntry[] | null;
output_map_source?: "auto" | "user" | null;
/** True when the graph has unknown output nodes needing designation. */
needs_mapping?: boolean;
/** Latest test-run record, queried live (HEL-643); null = never tested. */
last_test?: WorkflowLastTest | null;
}
/**
* HEL-682: metadata-only row (GET /api/workflows?view=meta) — the whole
* point is browsing 150+ workflows without downloading workflow_json.
*/
export interface WorkflowMediaIO {
in: string[];
out: string[];
}
export interface WorkflowMeta {
id: number;
name: string;
kind: "image" | "video";
usage: WorkflowUsage | null;
usage_source: "auto" | "user" | null;
is_enabled: boolean;
category: string | null;
model_family: string | null;
media_io: WorkflowMediaIO;
tags: string[];
favorite: boolean;
last_used_at: string | null;
description: string | null;
source: string | null;
source_path: string | null;
prompt_profile: string;
created_at: string;
endpoint_id: string | null;
output_map_source: "auto" | "user" | null;
/** Cheap pending check: usage or output mapping not settled yet. */
needs_mapping: boolean;
}
/** One UI-format file submitted to the batch convert/import pipeline. */
export interface WorkflowUIFileInput {
name?: string;
source_path?: string;
workflow_json: Record<string, unknown>;
usage?: WorkflowUsage;
description?: string;
tags?: string[];
}
export interface WorkflowConvertIssue {
node_id: string;
code: string;
level: "error" | "warning";
message: string;
}
export interface WorkflowConvertSuggested {
kind: "image" | "video";
usage: WorkflowUsage | null;
usage_ambiguous: boolean;
category: string | null;
category_label: string | null;
model_family: string | null;
media_in: string[];
media_out: string[];
description: string | null;
}
export interface WorkflowPreflightFinding {
level: string;
code: string;
node_id?: string;
node_title?: string;
message: string;
hint?: string;
}
export interface WorkflowConvertFileReport {
name: string;
source_path: string | null;
status: "ok" | "warn" | "fail";
needs_confirmation: boolean;
issues: WorkflowConvertIssue[];
importable: boolean;
skip_reason?: string | null;
suggested?: WorkflowConvertSuggested;
preflight?: {
endpoint_id: string | null;
ok: boolean;
findings: WorkflowPreflightFinding[];
};
}
/** Dry-run report (POST /convert, POST /import-scan with dry_run). */
export interface WorkflowConvertReport {
total: number;
ok: number;
warn: number;
fail: number;
needs_confirmation: number;
importable: number;
files: WorkflowConvertFileReport[];
}
export interface WorkflowImportFileReport extends WorkflowConvertFileReport {
result?: "imported" | "skipped" | "failed";
id?: number;
existing_id?: number;
}
/** Commit result (POST /import-batch, POST /import-scan without dry_run). */
export interface WorkflowImportResult {
total: number;
imported: number;
skipped: number;
imported_ids: number[];
skipped_files: {
name: string;
source_path: string | null;
reason: string | null;
}[];
files: WorkflowImportFileReport[];
}
export interface Job {
id: number;
project_id: number;
scene_id: number | null;
/** Render unit — video jobs are clip jobs (scene_id stays for grouping). */
clip_id: number | null;
kind: string;
workflow_id: number | null;
status: string;
payload: Record<string, unknown>;
output_paths: string[];
error: string | null;
created_at: string;
started_at: string | null;
completed_at: string | null;
retry_count: number;
id: number;
project_id: number;
scene_id: number | null;
/** Render unit — video jobs are clip jobs (scene_id stays for grouping). */
clip_id: number | null;
kind: string;
workflow_id: number | null;
status: string;
payload: Record<string, unknown>;
output_paths: string[];
error: string | null;
created_at: string;
started_at: string | null;
completed_at: string | null;
retry_count: number;
/** Prompt id ComfyUI accepted this job under (null until /prompt succeeds). */
comfy_prompt_id?: string | null;
/** connecting · comfy_queued · comfy_running · saving · finished · dry_run */
phase?: string | null;
/** 'user' (normal generation) or 'workflow_test' (HEL-643 test run). */
origin?: string;
/** HEL-684: present when this take has been flowed back to its
* workbench source row (link job id on the source project). */
workbench_attach?: {
job_id: number;
stage: string;
target_id: string;
};
}
export interface SceneVideoSettings {
input_values?: Record<string, string | number>;
/** 'auto' | 'upload' | target scene id as string. */
clip_source?: string;
form_workflow_id?: number;
prompt_draft?: string;
prompt_draft_meta?: { based_on?: string; saved_at?: string };
[key: string]: unknown;
input_values?: Record<string, string | number>;
/** 'auto' | 'upload' | target scene id as string. */
clip_source?: string;
form_workflow_id?: number;
prompt_draft?: string;
prompt_draft_meta?: { based_on?: string; saved_at?: string };
[key: string]: unknown;
}
export interface Scene {
id: number;
project_id: number;
order_index: number;
heading: string | null;
action: string | null;
dialog: string | null;
duration_sec: number | null;
workflow_id: number | null;
env_image_path: string | null;
location_id: number | null;
video_path: string | null;
chain_from_prev?: number | boolean | null;
video_settings?: SceneVideoSettings | null;
character_ids: number[];
characters: Array<{
id: number;
name: string;
role: string | null;
portrait_path: string | null;
sheet_path: string | null;
}>;
clips: Clip[];
id: number;
project_id: number;
order_index: number;
heading: string | null;
action: string | null;
dialog: string | null;
duration_sec: number | null;
workflow_id: number | null;
env_image_path: string | null;
location_id: number | null;
video_path: string | null;
chain_from_prev?: number | boolean | null;
video_settings?: SceneVideoSettings | null;
character_ids: number[];
characters: Array<{
id: number;
name: string;
role: string | null;
portrait_path: string | null;
sheet_path: string | null;
}>;
clips: Clip[];
}
/** Shot unit: one scene expands into 1+ clips; each renders one ~5-10s video. */
export interface Clip {
id: number;
scene_id: number;
project_id: number;
order_index: number;
description: string | null;
shot_size: string | null;
dialog_lines_covered: number[] | null;
duration_sec: number | null;
workflow_id: number | null;
clip_path: string | null;
chain_from_prev?: number | boolean | null;
video_settings?: SceneVideoSettings | null;
/** '#3.2' — scene 3, clip 2 (display label). */
label?: string;
id: number;
scene_id: number;
project_id: number;
order_index: number;
description: string | null;
shot_size: string | null;
dialog_lines_covered: number[] | null;
duration_sec: number | null;
workflow_id: number | null;
clip_path: string | null;
chain_from_prev?: number | boolean | null;
video_settings?: SceneVideoSettings | null;
/** '#3.2' — scene 3, clip 2 (display label). */
label?: string;
}
+74 -70
View File
@@ -8,92 +8,96 @@
* - upload progress per nodeId
* - metadata (name, kind) for uploaded files
*/
import { playgroundApi, type UploadKind } from '$lib/api';
import { toast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
import { playgroundApi, type UploadKind } from "$lib/api";
import { toast } from "$lib/toast";
import { t } from "$lib/i18n.svelte";
export interface UploadState {
/** nodeId → uploading filename (present while upload is in-flight) */
uploading: Record<string, string>;
/** path → { name, kind } for previously uploaded files */
uploadedMeta: Record<string, { name: string; kind: UploadKind }>;
/** nodeId → uploading filename (present while upload is in-flight) */
uploading: Record<string, string>;
/** path → { name, kind } for previously uploaded files */
uploadedMeta: Record<string, { name: string; kind: UploadKind }>;
}
export function createUploadManager() {
let uploading = $state<Record<string, string>>({});
let uploadedMeta = $state<Record<string, { name: string; kind: UploadKind }>>({});
let uploading = $state<Record<string, string>>({});
let uploadedMeta = $state<Record<string, { name: string; kind: UploadKind }>>(
{},
);
return {
get uploading() {
return uploading;
},
get uploadedMeta() {
return uploadedMeta;
},
return {
get uploading() {
return uploading;
},
get uploadedMeta() {
return uploadedMeta;
},
isUploading(nodeId: string): boolean {
return !!uploading[nodeId];
},
isUploading(nodeId: string): boolean {
return !!uploading[nodeId];
},
uploadMetaFor(path: string): { name: string; kind: UploadKind } {
const meta = uploadedMeta[path];
if (meta) return meta;
const base = path.split(/[/\\]/).pop() ?? path;
const ext = base.slice(base.lastIndexOf('.')).toLowerCase();
if (['.mp4', '.webm', '.mov', '.mkv'].includes(ext))
return { name: base, kind: 'video' };
if (['.mp3', '.wav', '.flac', '.ogg', '.m4a'].includes(ext))
return { name: base, kind: 'audio' };
return { name: base, kind: 'image' };
},
uploadMetaFor(path: string): { name: string; kind: UploadKind } {
const meta = uploadedMeta[path];
if (meta) return meta;
const base = path.split(/[/\\]/).pop() ?? path;
const ext = base.slice(base.lastIndexOf(".")).toLowerCase();
if ([".mp4", ".webm", ".mov", ".mkv"].includes(ext))
return { name: base, kind: "video" };
if ([".mp3", ".wav", ".flac", ".ogg", ".m4a"].includes(ext))
return { name: base, kind: "audio" };
return { name: base, kind: "image" };
},
/**
* Upload a file and return the server path.
* Manages uploading state and metadata internally.
* Throws on failure (caller handles toast if needed).
*/
async upload(nodeId: string, file: File): Promise<string> {
uploading = { ...uploading, [nodeId]: file.name };
try {
const res = await playgroundApi.upload(file);
uploadedMeta = {
...uploadedMeta,
[res.path]: { name: res.name, kind: res.kind },
};
return res.path;
} finally {
const next = { ...uploading };
delete next[nodeId];
uploading = next;
}
},
/**
* Upload a file and return the server path.
* Manages uploading state and metadata internally.
* Throws on failure (caller handles toast if needed).
*/
async upload(nodeId: string, file: File): Promise<string> {
uploading = { ...uploading, [nodeId]: file.name };
try {
const res = await playgroundApi.upload(file);
uploadedMeta = {
...uploadedMeta,
[res.path]: { name: res.name, kind: res.kind },
};
return res.path;
} finally {
const next = { ...uploading };
delete next[nodeId];
uploading = next;
}
},
/**
* Convenience: upload + toast on error, return path or null.
*/
async uploadSafe(nodeId: string, file: File): Promise<string | null> {
try {
return await this.upload(nodeId, file);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('toast.uploadFailed'));
return null;
}
},
};
/**
* Convenience: upload + toast on error, return path or null.
*/
async uploadSafe(nodeId: string, file: File): Promise<string | null> {
try {
return await this.upload(nodeId, file);
} catch (err) {
toast.error(
err instanceof Error ? err.message : t("toast.uploadFailed"),
);
return null;
}
},
};
}
export type UploadManager = ReturnType<typeof createUploadManager>;
export function truncateMiddle(name: string, max = 32): string {
if (name.length <= max) return name;
const head = Math.ceil((max - 1) / 2);
const tail = Math.floor((max - 1) / 2);
return `${name.slice(0, head)}…${name.slice(name.length - tail)}`;
if (name.length <= max) return name;
const head = Math.ceil((max - 1) / 2);
const tail = Math.floor((max - 1) / 2);
return `${name.slice(0, head)}…${name.slice(name.length - tail)}`;
}
export function acceptForKind(kind: string): string {
if (kind === 'audio') return 'audio/*';
if (kind === 'video') return 'video/*,.mp4,.webm,.mov,.mkv';
if (kind === 'document') return '.txt,.md,.docx';
return 'image/*';
if (kind === "audio") return "audio/*";
if (kind === "video") return "video/*,.mp4,.webm,.mov,.mkv";
if (kind === "document") return ".txt,.md,.docx";
return "image/*";
}
@@ -1,614 +0,0 @@
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import { jobsApi, type Job } from '$lib/api';
import type { CalliopeEvent, EventConnectionState } from '$lib/events';
import { t } from '$lib/i18n.svelte';
import JobRow from './JobRow.svelte';
import Button from './ui/Button.svelte';
import Icon from './ui/Icon.svelte';
interface Props {
logs: CalliopeEvent[];
projectId: number;
/** Real SSE connection state from events.ts — drives the live pip. */
connState: EventConnectionState;
collapsed?: boolean;
onToggle?: () => void;
}
let { logs, projectId, connState, collapsed = false, onToggle }: Props = $props();
type Tone = 'info' | 'ok' | 'warn' | 'err' | 'work';
type LogEntry = {
key: string;
ts: string;
title: string;
detail: string;
tone: Tone;
type: string;
};
function str(v: unknown): string {
if (v == null) return '';
if (typeof v === 'string') return v;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
return '';
}
function formatEvent(ev: CalliopeEvent, index: number): LogEntry | null {
// Poll spam — progress lives in the jobProgress store, not the log
if (ev.type === 'job.progress') return null;
const d = ev.data ?? {};
const msg = str(d.message).trim();
const kind = str(d.kind);
const jobId = d.job_id != null ? `#${d.job_id}` : '';
const err = str(d.error).trim();
const paths = Array.isArray(d.paths) ? d.paths.length : 0;
const outputs = Array.isArray(d.outputs) ? d.outputs.length : 0;
const time = ev.ts?.includes('T') ? ev.ts.slice(11, 19) : ev.ts?.slice(0, 8) || '';
let title = ev.type;
let detail = msg;
let tone: Tone = 'info';
switch (ev.type) {
case 'agent.thinking':
title = t('activity.agentWorking');
detail = msg || t('activity.working');
tone = 'work';
break;
case 'story.ready':
title = t('activity.storyReady');
detail = msg || t('activity.storylineDrafted');
tone = 'ok';
break;
case 'job.created':
title = t('activity.queued');
detail = msg || [kind, jobId].filter(Boolean).join(' ');
tone = 'info';
break;
case 'job.started':
title = t('activity.running');
detail = msg || [kind, jobId].filter(Boolean).join(' ');
tone = 'work';
break;
case 'job.completed':
title = t('activity.finished');
detail =
msg ||
[kind, jobId, outputs ? t('activity.files', { count: outputs }) : '']
.filter(Boolean)
.join(' · ');
tone = 'ok';
break;
case 'job.failed':
title = t('activity.failed');
detail = err || msg || [kind, jobId].filter(Boolean).join(' ');
tone = 'err';
break;
case 'asset.ready':
title = t('activity.assetReady');
detail = msg || (paths ? t('activity.filesSaved', { count: paths }) : t('activity.outputSaved'));
tone = 'ok';
break;
case 'job.deleted':
title = t('activity.removed');
detail = msg || jobId || t('activity.jobDeleted');
tone = 'warn';
break;
default:
title = ev.type.replace(/\./g, ' · ');
detail = msg || err || kind;
tone = 'info';
}
return {
key: `${ev.ts}-${ev.type}-${index}`,
ts: time,
title,
detail,
tone,
type: ev.type,
};
}
const entries = $derived.by(() => {
const raw = logs
.slice(-120)
.map((ev, i) => formatEvent(ev, i))
.filter((e): e is LogEntry => e != null);
// Collapse consecutive identical agent lines
const collapsed: LogEntry[] = [];
for (const e of raw) {
const prev = collapsed[collapsed.length - 1];
if (prev && prev.type === e.type && prev.detail === e.detail) {
continue;
}
collapsed.push(e);
}
return collapsed.slice(-60).reverse();
});
// Same query key as QueueStage — refreshed by SSE invalidation from the
// project page (job.* events), not by polling.
const jobsQuery = createQuery(
toStore(() => ({
queryKey: ['jobs', projectId],
queryFn: () => jobsApi.list(projectId),
})),
);
const allJobs = $derived(($jobsQuery.data ?? []) as Job[]);
function activeWeight(job: Job): number {
if (job.status === 'running') return 0;
if (job.status === 'pending') return 1;
return 2;
}
// Active jobs first, then most recent — capped so the log keeps room.
const queueRows = $derived(
[...allJobs].sort((a, b) => activeWeight(a) - activeWeight(b) || b.id - a.id).slice(0, 6),
);
const stats = $derived({
running: allJobs.filter((j) => j.status === 'running').length,
queued: allJobs.filter((j) => j.status === 'pending').length,
done: allJobs.filter((j) => j.status === 'done').length,
failed: allJobs.filter((j) => j.status === 'failed').length,
});
const busyCount = $derived(stats.running + stats.queued);
const connected = $derived(connState === 'open');
</script>
<aside class="activity" class:collapsed aria-label={t('activity.title')}>
{#if collapsed}
<button
type="button"
class="rail-toggle"
onclick={onToggle}
aria-expanded="false"
title={t('activity.show')}
>
<span class="pip rail-pip" class:live={connected} class:reconnecting={connState === 'reconnecting'} aria-hidden="true"></span>
{#if busyCount > 0}
<span class="rail-count" title={t('activity.busyCount', { count: busyCount })}>{busyCount}</span>
{/if}
<span class="rail-label">{t('activity.title')}</span>
<span class="rail-chevron" aria-hidden="true">‹</span>
</button>
{:else}
<header class="head">
<div class="head-title">
<span class="pip" class:live={connected} class:reconnecting={connState === 'reconnecting'} aria-hidden="true"></span>
<strong>{t('activity.title')}</strong>
{#if connState === 'reconnecting'}
<span class="conn-state warn">{t('activity.reconnecting')}</span>
{:else if connState === 'connecting'}
<span class="conn-state">{t('activity.connecting')}</span>
{/if}
</div>
<Button variant="ghost" size="sm" onclick={onToggle} title={t('activity.hide')}>
{t('activity.hide')} <Icon name="chevron-right" size={13} />
</Button>
</header>
<div class="body">
<section class="queue" aria-label={t('activity.queue')}>
<div class="section-head">
<p class="eyebrow">{t('activity.queue')}</p>
</div>
{#if queueRows.length === 0}
<p class="queue-empty">{t('activity.queueIdle')}</p>
{:else}
<div class="job-list">
{#each queueRows as job (job.id)}
<JobRow {job} />
{/each}
</div>
{/if}
<div class="stats-strip" aria-label={t('activity.queueStats')}>
<span><span class="sd sd-running" aria-hidden="true"></span>{stats.running} {t('activity.running').toLowerCase()}</span>
<span><span class="sd sd-queued" aria-hidden="true"></span>{stats.queued} {t('activity.queued').toLowerCase()}</span>
<span><span class="sd sd-done" aria-hidden="true"></span>{stats.done} {t('activity.done')}</span>
<span><span class="sd sd-failed" aria-hidden="true"></span>{stats.failed} {t('activity.failed').toLowerCase()}</span>
</div>
</section>
<section class="log-section" aria-label={t('activity.agentLog')}>
<p class="eyebrow">{t('activity.agentLog')}</p>
{#if entries.length === 0}
<div class="empty">
<p>{t('activity.quiet')}</p>
<p class="hint">{t('activity.quietHint')}</p>
</div>
{:else}
<ul class="log">
{#each entries as ev (ev.key)}
<li class="entry tone-{ev.tone}">
<span class="dot" aria-hidden="true"></span>
<div class="meta">
<div class="row">
<span class="title">{ev.title}</span>
<time class="ts" datetime={ev.ts}>{ev.ts}</time>
</div>
{#if ev.detail}
<p class="detail">{ev.detail}</p>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</section>
</div>
{/if}
</aside>
<style>
.activity {
--panel-w: 320px;
--rail-w: 44px;
width: var(--panel-w);
flex-shrink: 0;
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
border-left: 1px solid var(--border);
background:
linear-gradient(180deg, rgba(139, 92, 246, 0.06) 0%, transparent 120px),
var(--bg-surface);
overflow: hidden;
transition: width 0.2s ease;
}
.activity.collapsed {
width: var(--rail-w);
background: var(--bg-surface);
}
.rail-toggle {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 12px;
width: 100%;
height: 100%;
margin: 0;
padding: 16px 0;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
overflow: hidden;
}
.rail-toggle:hover {
background: rgba(139, 92, 246, 0.08);
color: var(--text-primary);
}
.rail-toggle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.rail-label {
writing-mode: vertical-rl;
transform: rotate(180deg);
font-family: var(--font-display);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.rail-chevron {
font-size: 18px;
line-height: 1;
color: var(--text-muted);
}
.rail-count {
display: flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 5px;
border-radius: 999px;
background: var(--accent);
color: #fff;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 700;
}
.pip {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
flex-shrink: 0;
}
.pip.live {
background: var(--accent);
box-shadow: 0 0 0 4px var(--accent-glow);
animation: live-pulse 1.6s ease-in-out infinite;
}
.pip.reconnecting {
background: var(--warning);
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.18);
}
@keyframes live-pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.65;
transform: scale(0.92);
}
}
@media (prefers-reduced-motion: reduce) {
.activity {
transition: none;
}
.pip.live {
animation: none;
}
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 14px 14px 10px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.head-title {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.head-title strong {
font-family: var(--font-display);
font-size: 15px;
font-weight: 650;
letter-spacing: -0.02em;
}
.conn-state {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
}
.conn-state.warn {
color: var(--warning);
}
.body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 12px 10px 16px;
overflow: hidden;
}
.queue {
flex-shrink: 0;
display: flex;
flex-direction: column;
min-height: 0;
margin-bottom: 14px;
}
.section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
flex-shrink: 0;
}
.job-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 216px;
overflow-y: auto;
padding-right: 4px;
}
.queue-empty {
margin: 0 6px 4px;
padding: 12px 14px;
border: 1px dashed var(--border);
border-radius: var(--radius-md);
background: rgba(0, 0, 0, 0.2);
font-size: 12px;
line-height: 1.45;
color: var(--text-muted);
}
.stats-strip {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
margin: 10px 6px 0;
padding-top: 10px;
border-top: 1px solid var(--border);
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-secondary);
flex-shrink: 0;
}
.stats-strip > span {
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
}
.sd {
width: 6px;
height: 6px;
border-radius: 50%;
}
.sd-running {
background: var(--info);
}
.sd-queued {
background: var(--text-muted);
}
.sd-done {
background: var(--success);
}
.sd-failed {
background: var(--error);
}
.log-section {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.eyebrow {
margin: 0 6px 10px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
flex-shrink: 0;
}
.empty {
margin: 8px 6px;
padding: 16px;
border: 1px dashed var(--border);
border-radius: var(--radius-md);
background: rgba(0, 0, 0, 0.2);
}
.empty p {
margin: 0;
font-size: 13px;
color: var(--text-secondary);
}
.empty .hint {
margin-top: 8px;
font-size: 12px;
color: var(--text-muted);
line-height: 1.45;
}
.log {
list-style: none;
margin: 0;
padding: 0 4px 8px 0;
overflow-y: auto;
overflow-x: hidden;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.entry {
display: grid;
grid-template-columns: 14px 1fr;
gap: 8px;
padding: 8px 8px 8px 4px;
border-radius: var(--radius-sm);
}
.entry:hover {
background: rgba(255, 255, 255, 0.03);
}
.dot {
width: 8px;
height: 8px;
margin-top: 5px;
border-radius: 50%;
background: var(--text-muted);
justify-self: center;
}
.tone-ok .dot {
background: var(--success);
}
.tone-err .dot {
background: var(--error);
}
.tone-warn .dot {
background: var(--warning);
}
.tone-work .dot {
background: var(--accent);
}
.tone-info .dot {
background: var(--info);
}
.meta {
min-width: 0;
}
.row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.title {
font-size: 12px;
font-weight: 650;
color: var(--text-primary);
}
.ts {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
flex-shrink: 0;
}
.detail {
margin: 3px 0 0;
font-size: 12px;
line-height: 1.4;
color: var(--text-secondary);
word-break: break-word;
}
.tone-err .detail {
color: #fca5a5;
}
</style>
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
import { t } from '$lib/i18n.svelte';
@@ -15,8 +16,8 @@ interface Props {
<header class="header">
<div class="header-left">
<a class="logo" href="/canvas" title="Calliope Lab">
Calliope <span>Lab</span>
<a class="logo" href={resolve('/')} title={t('shell.brand')}>
{t('shell.brand')}
</a>
{#if crumb}
<span class="crumb">{crumb}</span>
@@ -27,13 +28,13 @@ interface Props {
</div>
{/if}
<nav class="top-nav" aria-label={t('nav.primary')}>
<a class="nav-link" class:active={active === 'canvas'} href="/canvas">{t('nav.canvas')}</a>
<a class="nav-link" class:active={active === 'projects'} href="/projects">{t('nav.projects')}</a>
<a class="nav-link" class:active={active === 'build-scene'} href="/build-scene">{t('nav.buildScene')}</a>
<a class="nav-link" class:active={active === 'library'} href="/library">{t('nav.library')}</a>
<a class="nav-link" class:active={active === 'agents'} href="/agents">{t('nav.agents')}</a>
<a class="nav-link" class:active={active === 'models'} href="/models">{t('nav.models')}</a>
<a class="nav-link" class:active={active === 'settings'} href="/settings">{t('nav.settings')}</a>
<a class="nav-link" class:active={active === 'canvas'} href={resolve('/canvas')}>{t('nav.canvas')}</a>
<a class="nav-link" class:active={active === 'projects'} href={resolve('/')}>{t('nav.projects')}</a>
<a class="nav-link" class:active={active === 'build-scene'} href={resolve('/build-scene')}>{t('nav.buildScene')}</a>
<a class="nav-link" class:active={active === 'library'} href={resolve('/library')}>{t('nav.library')}</a>
<a class="nav-link" class:active={active === 'agents'} href={resolve('/agents')}>{t('nav.agents')}</a>
<a class="nav-link" class:active={active === 'models'} href={resolve('/models')}>{t('nav.models')}</a>
<a class="nav-link" class:active={active === 'settings'} href={resolve('/settings')}>{t('nav.settings')}</a>
</nav>
</div>
<div class="header-right">
@@ -69,18 +70,14 @@ interface Props {
flex-shrink: 0;
}
.logo {
font-family: var(--font-display);
font-size: 18px;
font-family: var(--font);
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
color: var(--text);
text-decoration: none;
letter-spacing: -0.02em;
letter-spacing: -0.01em;
white-space: nowrap;
}
.logo span {
color: var(--accent);
font-weight: 500;
}
.crumb {
color: var(--text-muted);
font-size: 13px;
@@ -122,7 +119,7 @@ interface Props {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@media (max-width: 640px) {
@media (max-width: 760px) {
.header {
padding: 12px 16px;
flex-direction: column;
@@ -1,133 +0,0 @@
<script lang="ts">
import { t } from '$lib/i18n.svelte';
import SafeMedia from '$lib/components/SafeMedia.svelte';
import StatusChip from '$lib/components/ui/StatusChip.svelte';
interface Props {
src: string | null;
alt: string;
placeholder: string;
wide?: boolean;
/** Taller 4:3 media-first crop (asset card grids). */
tall?: boolean;
/** Latest generation-job state for this asset, if the parent tracks jobs. */
jobState?: 'generating' | 'failed' | null;
onPreview?: (src: string) => void;
}
let {
src,
alt,
placeholder,
wide = false,
tall = false,
jobState = null,
onPreview,
}: Props = $props();
let available = $state(false);
$effect(() => {
void src;
available = false;
});
// Job state (live from the queue) wins over the media-derived state.
const chip = $derived.by((): { status: string; label: string } => {
if (jobState === 'generating') return { status: 'generating', label: t('common.generating') };
if (jobState === 'failed') return { status: 'failed', label: t('common.failed') };
if (available) return { status: 'ready', label: t('common.ready') };
if (src) return { status: 'missing', label: t('common.missing') };
return { status: 'pending', label: t('common.pending') };
});
function open() {
if (available && src && onPreview) onPreview(src);
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
open();
}
}
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
<div
class="thumb"
class:wide
class:tall
class:clickable={available && !!onPreview}
role={available && onPreview ? 'button' : undefined}
tabindex={available && onPreview ? 0 : undefined}
onclick={open}
onkeydown={available && onPreview ? onKey : undefined}
>
<SafeMedia
class="fill"
{src}
{alt}
label={placeholder}
onAvailable={() => (available = true)}
onUnavailable={() => (available = false)}
/>
<span class="thumb-chip">
<StatusChip status={chip.status} label={chip.label} />
</span>
</div>
<style>
.thumb {
aspect-ratio: 1;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
padding: 0;
margin: 0;
width: 100%;
color: inherit;
font: inherit;
box-sizing: border-box;
}
.thumb.wide {
aspect-ratio: 16 / 10;
}
.thumb.tall {
aspect-ratio: 4 / 3;
}
.thumb.clickable {
cursor: zoom-in;
}
.thumb.clickable:hover {
border-color: var(--accent, #a78bfa);
}
.thumb.clickable:focus-visible {
outline: 2px solid var(--accent, #a78bfa);
outline-offset: 2px;
}
.thumb :global(.fill) {
width: 100%;
height: 100%;
object-fit: cover;
border: none;
border-radius: 0;
min-height: 0;
}
.thumb :global(.safe-media-ph.fill) {
position: absolute;
inset: 0;
}
.thumb-chip {
position: absolute;
top: 8px;
left: 8px;
z-index: 1;
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
}
</style>
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { createMutation, createQuery } from '@tanstack/svelte-query';
import {
playgroundApi,
@@ -136,10 +137,7 @@
const projectList = $derived(($projectsQuery.data ?? []) as Project[]);
// Stage-URL contract: images land on Assets, clips on Video.
const stageLink = $derived(
attached ? `/project/${attached.projectId}?stage=${isVideo ? 'video' : 'assets'}` : '',
);
// Five-stage workbench URL contract: images land on 素材资产, clips on 镜头脚本.
</script>
<div class="attach">
@@ -147,7 +145,7 @@
<div class="attached" role="status">
<span class="attached-check" aria-hidden="true"><Icon name="check" size={14} /></span>
<span class="attached-text">{t('attach.addedTo')} <strong>{attached.title}</strong></span>
<a class="attached-link" href={stageLink}>{t('attach.viewInProject')}</a>
<a class="attached-link" href={resolve(`/workbench/${attached.projectId}?stage=${isVideo ? 'shot' : 'assets'}`)}>{t('attach.viewInProject')}</a>
<button
class="attached-dismiss"
type="button"
@@ -173,7 +171,7 @@
<span class="field-label">{t('attach.projectField')}</span>
<select class="field-select" bind:value={projectId}>
<option value="">{t('attach.selectProject')}</option>
{#each projectList as p}
{#each projectList as p (p.id)}
<option value={p.id}>{p.title}</option>
{/each}
</select>
@@ -194,7 +192,7 @@
<span class="field-label">{t('attach.characterField')}</span>
<select class="field-select" bind:value={characterId} disabled={projectId === '' || loadingTargets}>
<option value="">{t('attach.selectCharacter')}</option>
{#each characters as c}
{#each characters as c (c.id)}
<option value={c.id}>{c.name}</option>
{/each}
</select>
@@ -207,7 +205,7 @@
<span class="field-label">{t('attach.locationField')}</span>
<select class="field-select" bind:value={locationId} disabled={projectId === '' || loadingTargets}>
<option value="">{t('attach.selectLocation')}</option>
{#each locations as loc}
{#each locations as loc (loc.id)}
<option value={loc.id}>{loc.name}</option>
{/each}
</select>
@@ -232,7 +230,7 @@
<span class="field-label">{t('attach.sceneField')}</span>
<select class="field-select" bind:value={sceneId} disabled={projectId === '' || loadingTargets}>
<option value="">{t('attach.selectScene')}</option>
{#each scenes as s}
{#each scenes as s (s.id)}
<option value={s.id}>#{s.order_index} {s.heading || t('attach.scene')}</option>
{/each}
</select>
@@ -267,13 +265,13 @@
.attached {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: rgba(34, 197, 94, 0.08);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: var(--radius-md);
font-size: 12px;
color: var(--text-secondary);
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
background: var(--ok-soft);
border: 1px solid color-mix(in srgb, var(--ok) 30%, transparent);
border-radius: var(--r-md);
font-size: var(--fs-aux);
color: var(--text-2);
}
.attached-check {
display: flex;
@@ -282,8 +280,8 @@
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--success);
color: #052e12;
background: var(--ok);
color: var(--bg);
flex-shrink: 0;
}
.attached-text {
@@ -293,23 +291,26 @@
white-space: nowrap;
}
.attached-text strong {
color: var(--text-primary);
color: var(--text);
font-weight: 600;
}
/* 普通链接走 ghost(§1.2):默认 --text-2,悬停 --text,不用金色文字链接。 */
.attached-link {
color: var(--accent);
font-weight: 600;
color: var(--text-2);
font-weight: 500;
text-decoration: none;
white-space: nowrap;
flex-shrink: 0;
}
.attached-link:hover {
color: var(--text);
text-decoration: underline;
text-underline-offset: 3px;
}
.attached-link:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
border-radius: var(--r-sm);
}
.attached-dismiss {
display: flex;
@@ -319,15 +320,15 @@
height: 22px;
margin-left: auto;
border: none;
border-radius: var(--radius-sm);
border-radius: var(--r-sm);
background: transparent;
color: var(--text-muted);
color: var(--text-3);
cursor: pointer;
flex-shrink: 0;
}
.attached-dismiss:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.06);
color: var(--text);
background: var(--bg-4);
}
.attached-dismiss:focus-visible {
outline: 2px solid var(--accent);
@@ -336,20 +337,20 @@
.panel {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-md);
gap: var(--sp-1);
padding: var(--sp-5);
background: var(--bg-3);
border: 1px solid var(--line);
border-radius: var(--r-md);
}
.actions {
display: flex;
gap: 8px;
margin-top: 4px;
gap: var(--sp-3);
margin-top: var(--sp-1);
}
.err {
margin: 4px 0 0;
font-size: 12px;
color: var(--error);
margin: var(--sp-1) 0 0;
font-size: var(--fs-aux);
color: var(--err);
}
</style>

Some files were not shown because too many files have changed in this diff Show More