ci / verify (push) Canceled after 0s
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
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()
|