189 lines
11 KiB
Python
189 lines
11 KiB
Python
"""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()
|