"""Native Dify model menus. Run inside the Dify API image; no vendor patch. stage APP_ID... --backup-dir PATH installs metadata and saves drafts, never publishes. publish checks the staged hashes and publishes atomically. Backups contain workflow configuration and must remain private on the server. """ import argparse import copy import json from pathlib import Path PROVIDER = '故事平台 · 分步骤模型' TEXT = [('deepseek-v4-1-flash-260910', 'DeepSeek V4.1 Flash'), ('doubao-seed-evolving', 'Doubao Seed Evolving'), ('doubao-seed-2-1-pro-260915', 'Doubao Seed 2.1 Pro'), ('doubao-seed-2-1-lite-260915', 'Doubao Seed 2.1 Lite')] BOOK = TEXT[1:] IMAGE = [('doubao-seedream-5-0-pro-260628', 'Seedream 5.0 Pro'), ('doubao-seedream-5-0-flash-260915', 'Seedream 5.0 Flash'), ('doubao-seedream-4-0-20260415', 'Seedream 4.0')] STEPS = {'classify': ('分类与故事拆分', TEXT, TEXT[0][0]), 'daily': ('审核今天实际事件', TEXT, TEXT[0][0]), 'write-book': ('全部故事统一编写整本', BOOK, BOOK[0][0]), 'illustrate': ('绘制该章情景插画', IMAGE, IMAGE[0][0])} def i18n(text): return {'en_US': text, 'zh_Hans': text} def schema(): paths = {} for step, (title, options, default) in STEPS.items(): props = {n: {'type': 'string', 'description': d} for n, d in [('context_id', '后台任务编号'), ('step_token', '后台临时执行凭证'), ('model', '模型'), ('custom_model', '自定义火山模型 ID 或 ep- 接入点') ]} props['model'].update(default=default, enum=[x[0] for x in options]+['__custom__']) props['custom_model']['default'] = '' if step != 'illustrate': props['prompt'] = {'type': 'string', 'description': '提示词'} else: props['chapter_index'] = {'type': 'integer', 'description': '章节序号'} paths['/steps/'+step] = {'post': { 'operationId': step.replace('-', '_'), 'summary': title, 'requestBody': {'required': True, 'content': {'application/json': {'schema': { 'type': 'object', 'properties': props, 'required': ['context_id', 'step_token', 'model']}}}}, 'responses': {'200': {'description': '执行状态、实际模型和统计,不含长者原文'}}}} return {'openapi': '3.0.3', 'info': {'title': PROVIDER, 'version': '1.0.0', 'description': '在各节点选择火山模型。草稿测试使用虚构示例;发布后用于后续线上任务。'}, 'servers': [{'url': 'http://model-bridge:8188'}], 'paths': paths} def convert_graph(graph, provider_id): graph = copy.deepcopy(graph) changed = set() for n in graph['nodes']: d = n['data']; step = n['id'] if step not in STEPS: continue if d['type'] == 'tool': if d.get('provider_id') != provider_id: raise ValueError('node already uses another provider') continue if d['type'] != 'http-request' or d['url'] != 'http://model-bridge:8188/steps/'+step: raise ValueError('unexpected model node; refusing to replace') body = d['body']['data'][0]['value'] # Iteration index is a native numeric variable, not a JSON literal. old = json.loads(body.replace('{{#pictures.item#}}', '0')) title, _, default = STEPS[step] # Dify's string editor persists mixed templates. A variable binding on # a string looks empty in 1.17 and is erased when the model is changed. params = {v: {'type': 'mixed', 'value': '{{#prepared.'+v+'#}}'} for v in ['context_id', 'step_token']} if step == 'illustrate': params['chapter_index'] = {'type': 'variable', 'value': ['pictures', 'item']} else: params['prompt'] = {'type': 'mixed', 'value': old.get('prompt', '')} inherit = {k: v for k, v in d.items() if k in ['isInIteration', 'isInLoop', 'iteration_id', 'selected']} n['data'] = dict(type='tool', title=title, desc='选择模型后先测试,再发布;当前运行任务保留原版本。', provider_type='api', provider_id=provider_id, provider_name=PROVIDER, tool_name=step.replace('-', '_'), tool_label=title, tool_node_version='2', tool_configurations={'model': {'type': 'constant', 'value': default}, 'custom_model': {'type': 'mixed', 'value': ''}}, tool_parameters=params, **inherit) changed.add(step) for n in graph['nodes']: d = n['data'] if d['type'] != 'code': continue variables = d.get('variables', []) if any(v.get('value_selector', [None])[0] in changed for v in variables): d['variables'] = [v for v in variables if v['variable'] != 'status_code'] for v in d['variables']: if v['value_selector'][0] in changed and v['value_selector'][1] == 'body': v['value_selector'][1] = 'text' d['code'] = d['code'].replace('body: str, status_code: int', 'body: str') d['code'] = '\n'.join(l for l in d['code'].split('\n') if 'if status_code != 200:' not in l) types = {n['id']: n['data']['type'] for n in graph['nodes']} for e in graph['edges']: e['data'].update(sourceType=types[e['source']], targetType=types[e['target']]) return graph def install_provider(account, tenant, session): from core.tools.entities.tool_entities import ApiProviderSchemaType, ToolParameter from core.plugin.entities.parameters import PluginParameterOption from core.tools.entities.common_entities import I18nObject from models.tools import ApiToolProvider from services.tools.api_tools_manage_service import ApiToolManageService from sqlalchemy import select p = session.scalar(select(ApiToolProvider).where(ApiToolProvider.tenant_id == tenant, ApiToolProvider.name == PROVIDER)) if p is None: ApiToolManageService.create_api_tool_provider(user_id=account.id, tenant_id=tenant, provider_name=PROVIDER, icon={'type': 'emoji', 'value': '🧠', 'background': '#EFF4FF'}, credentials={'auth_type': 'none'}, schema_type=ApiProviderSchemaType.OPENAPI, schema=json.dumps(schema(), ensure_ascii=False), privacy_policy='', custom_disclaimer='', labels=[]) p = session.scalar(select(ApiToolProvider).where(ApiToolProvider.tenant_id == tenant, ApiToolProvider.name == PROVIDER)) # Dify 1.17's OpenAPI parser discards enum/form. Enrich its native serialized # bundles so the supported SELECT UI and runtime read the same metadata. bundles, _ = ApiToolManageService.convert_schema_to_tool_bundles(json.dumps(schema(), ensure_ascii=False), {}) for b in bundles: _, options, default = STEPS[b.operation_id.replace('_', '-')] for param in b.parameters: if param.name == 'model': param.type = ToolParameter.ToolParameterType.SELECT param.form = ToolParameter.ToolParameterForm.FORM param.label = I18nObject(**i18n('模型')) param.default = default param.options = [PluginParameterOption(value=v, label=i18n(label)) for v, label in options]+[ PluginParameterOption(value='__custom__', label=i18n('自定义模型 / 接入点'))] elif param.name == 'custom_model': param.form = ToolParameter.ToolParameterForm.FORM param.label = I18nObject(**i18n('自定义模型 ID(选择自定义时填写)')) param.default = '' else: param.form = ToolParameter.ToolParameterForm.LLM p.schema = json.dumps(schema(), ensure_ascii=False) p.tools_str = json.dumps([b.model_dump(mode='json') for b in bundles], ensure_ascii=False) session.commit() return p.id def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('mode', choices=['stage', 'publish', 'rollback']) parser.add_argument('apps', nargs='+') parser.add_argument('--backup-dir', required=True) args = parser.parse_args() from app_factory import create_app from extensions.ext_database import db from models import App, Workflow from models.account import Account from services.workflow_service import WorkflowService from sqlalchemy import select from sqlalchemy.orm import sessionmaker _, flask = create_app() dest = Path(args.backup_dir); dest.mkdir(parents=True, exist_ok=True) dest.chmod(0o700) with flask.app_context(), sessionmaker(db.engine, expire_on_commit=False)() as session: apps = [session.get(App, id) for id in args.apps] assert all(apps) and len({a.tenant_id for a in apps}) == 1 account = session.get(Account, apps[0].created_by) service = WorkflowService() if args.mode == 'stage': provider = install_provider(account, apps[0].tenant_id, session) receipts = [] for a in apps: draft = session.scalar(select(Workflow).where(Workflow.app_id == a.id, Workflow.version == 'draft')) target = dest/(a.id+'.json') if args.mode == 'stage': if target.exists(): raise ValueError('backup exists; use a new backup directory') published = session.get(Workflow, a.workflow_id) backup = {'app_id': a.id, 'published_id': a.workflow_id, 'draft_graph': draft.graph_dict, 'published_graph': published.graph_dict, 'original_hash': draft.unique_hash} graph = convert_graph(draft.graph_dict, provider) service.sync_draft_workflow(app_model=a, graph=graph, features=draft.features_dict, unique_hash=draft.unique_hash, account=account, environment_variables=[], conversation_variables=[], session=session, graph_only=True, commit=False) session.flush() backup['staged_hash'] = draft.unique_hash target.write_text(json.dumps(backup, ensure_ascii=False, indent=2)); target.chmod(0o600) receipts.append({'app_id': a.id, 'provider_id': provider, 'staged_hash': draft.unique_hash}) elif args.mode == 'publish': backup = json.loads(target.read_text()) if a.workflow_id != backup['published_id'] or draft.unique_hash != backup['staged_hash']: raise ValueError('workflow changed since staging; aborting publish') published = service.publish_workflow(session=session, app_model=a, account=account, marked_name='各步骤模型可配置', marked_comment='保留原模型和提示词;模型在节点菜单中选择。') a.workflow_id = published.id receipts.append({'app_id': a.id, 'workflow_id': published.id}) else: backup = json.loads(target.read_text()) service.sync_draft_workflow(app_model=a, graph=backup['draft_graph'], features=draft.features_dict, unique_hash=draft.unique_hash, account=account, environment_variables=[], conversation_variables=[], session=session, graph_only=True, commit=False) a.workflow_id = backup['published_id'] receipts.append({'app_id': a.id, 'restored': True}) session.commit() print(json.dumps({'mode': args.mode, 'workflows': receipts}, ensure_ascii=False)) if __name__ == '__main__': main()